Procházet zdrojové kódy

Merge remote-tracking branch 'origin/sit' into uat-20260202

dujian před 17 hodinami
rodič
revize
4b5f1b0fb2
24 změnil soubory, kde provedl 1440 přidání a 183 odebrání
  1. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTask.java
  2. 3 3
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushTaskStatisticsItemVo.java
  3. 34 28
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskMapper.java
  4. 16 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskUserMapper.java
  5. 9 0
      alien-entity/src/main/java/shop/alien/mapper/LifeUserMapper.java
  6. 3 1
      alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java
  7. 8 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  8. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushTaskStatsJob.java
  9. 9 4
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java
  10. 9 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java
  11. 8 0
      alien-store/src/main/java/shop/alien/store/controller/LifeUserController.java
  12. 20 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushChannelStatsDto.java
  13. 5 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java
  14. 37 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushVendorSendResult.java
  15. 8 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java
  16. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskStatsService.java
  17. 8 0
      alien-store/src/main/java/shop/alien/store/service/LifeUserService.java
  18. 78 8
      alien-store/src/main/java/shop/alien/store/service/channel/ApnsPushClient.java
  19. 505 125
      alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java
  20. 116 11
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java
  21. 64 3
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java
  22. 279 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskStatsServiceImpl.java
  23. 51 0
      alien-store/src/main/java/shop/alien/store/util/CommonPushPhoneType.java
  24. 109 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

+ 3 - 3
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushTaskStatisticsItemVo.java

@@ -41,13 +41,13 @@ public class CommonPushTaskStatisticsItemVo implements Serializable {
     @ApiModelProperty("目标用户数")
     private Integer estimatedCount;
 
-    @ApiModelProperty("实际发送数(status=2或3)")
+    @ApiModelProperty("实际发送数(common_push_task_num.real_send 汇总)")
     private Long sentCount;
 
-    @ApiModelProperty("送达数(status=3)")
+    @ApiModelProperty("送达数(common_push_task_num.real_delivered 汇总)")
     private Long deliveredCount;
 
-    @ApiModelProperty("点击数(user_add=1)")
+    @ApiModelProperty("点击数(common_push_task_num.click_sum 汇总)")
     private Long clickCount;
 
     @ApiModelProperty("推送率(实际发送数/目标用户数,%)")

+ 34 - 28
alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskMapper.java

@@ -15,13 +15,19 @@ import java.util.List;
 @Mapper
 public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
 
+    String NUM_SUM_REAL_SEND = "IFNULL(SUM(CAST(IFNULL(n.real_send, 0) AS UNSIGNED)), 0)";
+    String NUM_SUM_REAL_DELIVERED = "IFNULL(SUM(CAST(IFNULL(n.real_delivered, 0) AS UNSIGNED)), 0)";
+    String NUM_SUM_CLICK = "IFNULL(SUM(CAST(IFNULL(n.click_sum, 0) AS UNSIGNED)), 0)";
+    String NUM_SUM_SHOW = "IFNULL(SUM(CAST(IFNULL(n.show_sum, 0) AS UNSIGNED)), 0)";
+    String NUM_JOIN = "LEFT JOIN common_push_task_num n ON n.push_task_id = t.id AND n.delete_flag = 0 ";
+
     @Select("<script>" +
             "SELECT " +
-            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
-            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
-            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            NUM_SUM_REAL_SEND + " AS sentCount, " +
+            NUM_SUM_REAL_DELIVERED + " AS deliveredCount, " +
+            NUM_SUM_CLICK + " AS clickCount " +
             "FROM common_push_task t " +
-            "LEFT JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            NUM_JOIN +
             "WHERE t.delete_flag = 0 " +
             "<if test='taskNo != null and taskNo != \"\"'>AND t.task_no = #{taskNo} </if>" +
             "<if test='keyword != null and keyword != \"\"'>AND (t.title LIKE CONCAT('%', #{keyword}, '%') OR t.task_no LIKE CONCAT('%', #{keyword}, '%')) </if>" +
@@ -30,8 +36,8 @@ public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
             "<if test='channel != null and channel != \"\"'>AND t.channels LIKE CONCAT('%', #{channel}, '%') </if>" +
             "<if test='startTime != null'>AND t.created_time &gt;= #{startTime} </if>" +
             "<if test='endTime != null'>AND t.created_time &lt; #{endTime} </if>" +
-            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
-            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
+            "<if test='startTime != null'>AND n.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND n.created_time &lt; #{endTime} </if>" +
             "</script>")
     CommonPushTaskStatsDto selectStatisticsSummary(@Param("taskNo") String taskNo,
                                                    @Param("keyword") String keyword,
@@ -44,12 +50,12 @@ public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
     @Select("<script>" +
             "SELECT t.id, t.task_no AS taskNo, t.title, t.push_type AS pushType, t.status, " +
             "t.channels, t.estimated_count AS estimatedCount, t.created_time AS createdTime, " +
-            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
-            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
-            "IFNULL(SUM(CASE WHEN u.show_info = 1 THEN 1 ELSE 0 END), 0) AS showCount, " +
-            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            NUM_SUM_REAL_SEND + " AS sentCount, " +
+            NUM_SUM_REAL_DELIVERED + " AS deliveredCount, " +
+            NUM_SUM_SHOW + " AS showCount, " +
+            NUM_SUM_CLICK + " AS clickCount " +
             "FROM common_push_task t " +
-            "LEFT JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            NUM_JOIN +
             "WHERE t.delete_flag = 0 " +
             "<if test='taskNo != null and taskNo != \"\"'>AND t.task_no = #{taskNo} </if>" +
             "<if test='keyword != null and keyword != \"\"'>AND (t.title LIKE CONCAT('%', #{keyword}, '%') OR t.task_no LIKE CONCAT('%', #{keyword}, '%')) </if>" +
@@ -58,8 +64,8 @@ public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
             "<if test='channel != null and channel != \"\"'>AND t.channels LIKE CONCAT('%', #{channel}, '%') </if>" +
             "<if test='startTime != null'>AND t.created_time &gt;= #{startTime} </if>" +
             "<if test='endTime != null'>AND t.created_time &lt; #{endTime} </if>" +
-            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
-            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
+            "<if test='startTime != null'>AND n.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND n.created_time &lt; #{endTime} </if>" +
             "GROUP BY t.id, t.task_no, t.title, t.push_type, t.status, t.channels, t.estimated_count, t.created_time " +
             "ORDER BY t.created_time DESC" +
             "</script>")
@@ -74,15 +80,15 @@ public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
 
     @Select("<script>" +
             "SELECT " +
-            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
-            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
-            "IFNULL(SUM(CASE WHEN u.show_info = 1 THEN 1 ELSE 0 END), 0) AS showCount, " +
-            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
-            "FROM common_push_task_user u " +
-            "WHERE u.delete_flag = 0 " +
-            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
-            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
-            "<if test='phoneType != null and phoneType != \"\"'>AND u.phone_type = #{phoneType} </if>" +
+            NUM_SUM_REAL_SEND + " AS sentCount, " +
+            NUM_SUM_REAL_DELIVERED + " AS deliveredCount, " +
+            NUM_SUM_SHOW + " AS showCount, " +
+            NUM_SUM_CLICK + " AS clickCount " +
+            "FROM common_push_task_num n " +
+            "WHERE n.delete_flag = 0 " +
+            "<if test='startTime != null'>AND n.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND n.created_time &lt; #{endTime} </if>" +
+            "<if test='phoneType != null and phoneType != \"\"'>AND n.phone_type = #{phoneType} </if>" +
             "</script>")
     CommonPushTaskStatsDto selectUserStats(@Param("startTime") Date startTime,
                                            @Param("endTime") Date endTime,
@@ -90,14 +96,14 @@ public interface CommonPushTaskMapper extends BaseMapper<CommonPushTask> {
 
     @Select("<script>" +
             "SELECT t.id, t.title, " +
-            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
-            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
-            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            NUM_SUM_REAL_SEND + " AS sentCount, " +
+            NUM_SUM_REAL_DELIVERED + " AS deliveredCount, " +
+            NUM_SUM_CLICK + " AS clickCount " +
             "FROM common_push_task t " +
-            "INNER JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            "INNER JOIN common_push_task_num n ON n.push_task_id = t.id AND n.delete_flag = 0 " +
             "WHERE t.delete_flag = 0 " +
-            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
-            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
+            "<if test='startTime != null'>AND n.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND n.created_time &lt; #{endTime} </if>" +
             "GROUP BY t.id, t.title " +
             "HAVING deliveredCount &gt; 0 " +
             "ORDER BY (clickCount / deliveredCount) DESC " +

+ 16 - 0
alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskUserMapper.java

@@ -1,7 +1,23 @@
 package shop.alien.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 import shop.alien.entity.store.CommonPushTaskUser;
+import shop.alien.entity.store.dto.CommonPushTaskStatsDto;
 
 public interface CommonPushTaskUserMapper extends BaseMapper<CommonPushTaskUser> {
+
+    @Select("<script>" +
+            "SELECT " +
+            "IFNULL(SUM(CASE WHEN status IN (1, 2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
+            "IFNULL(SUM(CASE WHEN status IN (1, 3) THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
+            "IFNULL(SUM(CASE WHEN show_info = 1 THEN 1 ELSE 0 END), 0) AS showCount, " +
+            "IFNULL(SUM(CASE WHEN user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            "FROM common_push_task_user " +
+            "WHERE delete_flag = 0 AND push_task_id = #{pushTaskId} " +
+            "<if test='phoneType != null and phoneType != \"\"'>AND phone_type = #{phoneType} </if>" +
+            "</script>")
+    CommonPushTaskStatsDto selectStatsByTaskAndPhoneType(@Param("pushTaskId") Long pushTaskId,
+                                                         @Param("phoneType") String phoneType);
 }

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/LifeUserMapper.java

@@ -68,4 +68,13 @@ public interface LifeUserMapper extends BaseMapper<LifeUser> {
     @Select("SELECT * " +
             "FROM life_user ${ew.customSqlSegment}")
     LifeUserVo getUserById(@Param(Constants.WRAPPER) QueryWrapper<LifeUserVo> queryWrapper);
+
+    /**
+     * 统计用户设备总数:device_id 为逗号拼接,按逗号分段计数后求和
+     */
+    @Select("SELECT IFNULL(SUM(CASE " +
+            "WHEN device_id IS NULL OR TRIM(device_id) = '' THEN 0 " +
+            "ELSE LENGTH(device_id) - LENGTH(REPLACE(device_id, ',', '')) + 1 END), 0) " +
+            "FROM life_user WHERE delete_flag = 0")
+    Long countTotalDeviceNum();
 }

+ 3 - 1
alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java

@@ -181,7 +181,9 @@ public class LifeUserService extends ServiceImpl<LifeUserGatewayMapper, LifeUser
         }
         LifeUser update = new LifeUser();
         update.setId(id);
-        update.setDeviceId(normalized);
+        //当原来的字段有值,在后面用逗号拼接
+        update.setDeviceId(user.getDeviceId() == null ? normalized : user.getDeviceId() + "," + normalized);
+//        update.setDeviceId(normalized);
         return lifeUserMapper.updateById(update) > 0 ? R.success("保存成功") : R.fail("保存失败");
     }
 

+ 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 - 4
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java

@@ -30,6 +30,11 @@ public class CommonPushTaskController {
 
     private final CommonPushTaskService commonPushTaskService;
 
+    /**
+     * 新增推送任务入口。
+     * <p>调用链:validateTaskForAdd → fillTaskDefaults → auditPushContent → save → savePushReview
+     * →(sendType=1 时)doSendTask → commonPushSendService.send</p>
+     */
     @ApiOperation("新增推送任务")
     @ApiOperationSupport(order = 1)
     @PostMapping("/add")
@@ -102,8 +107,8 @@ public class CommonPushTaskController {
             @ApiImplicitParam(name = "status", value = "任务状态:0-草稿 1-待审核 2-已发送 3-已送达 4-失败 5-审核通过 6-驳回", dataType = "Integer", paramType = "query"),
             @ApiImplicitParam(name = "pushType", value = "推送类型:1-交易类 2-系统类 3-运营类 4-社交类", dataType = "Integer", paramType = "query"),
             @ApiImplicitParam(name = "channel", value = "推送渠道,如 notification/inapp", dataType = "String", paramType = "query"),
-            @ApiImplicitParam(name = "startTime", value = "开始时间", dataType = "Date", paramType = "query"),
-            @ApiImplicitParam(name = "endTime", value = "结束时间", dataType = "Date", paramType = "query")
+            @ApiImplicitParam(name = "startTime", value = "开始时间(传日期包含当天 00:00:00 起)", dataType = "Date", paramType = "query"),
+            @ApiImplicitParam(name = "endTime", value = "结束时间(传日期包含当天 23:59:59 止)", dataType = "Date", paramType = "query")
     })
     @GetMapping("/statisticsList")
     public R<CommonPushTaskStatisticsPageVo> statisticsList(
@@ -122,7 +127,7 @@ public class CommonPushTaskController {
     @ApiOperation("推送漏斗统计")
     @ApiOperationSupport(order = 7)
     @ApiImplicitParams({
-            @ApiImplicitParam(name = "phoneType", value = "手机设备类型,筛选 common_push_task_user.phone_type,为空则统计全部", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "phoneType", value = "机型:1-OPPO 2-VIVO 3-华为 4-苹果 5-荣耀 6-小米 7-三星,筛选 common_push_task_num.phone_type,为空则统计全部", dataType = "String", paramType = "query"),
             @ApiImplicitParam(name = "startTime", value = "开始时间", dataType = "Date", paramType = "query"),
             @ApiImplicitParam(name = "endTime", value = "结束时间", dataType = "Date", paramType = "query")
     })
@@ -141,7 +146,7 @@ public class CommonPushTaskController {
         return commonPushTaskService.sendTest(dto);
     }
 
-    @ApiOperation("报表中心(日报/周报/月报)")
+    @ApiOperation(value = "报表中心(日报/周报/月报)", notes = "概览与 TOP 榜单均基于 common_push_task_num 汇总")
     @ApiOperationSupport(order = 8)
     @ApiImplicitParams({
             @ApiImplicitParam(name = "reportType", value = "报表类型:1-日报 2-周报 3-月报", dataType = "Integer", paramType = "query")

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

+ 8 - 0
alien-store/src/main/java/shop/alien/store/controller/LifeUserController.java

@@ -250,5 +250,13 @@ public class LifeUserController {
         return R.data(true);
     }
 
+    @ApiOperation("统计用户设备数")
+    @ApiOperationSupport(order = 7)
+    @GetMapping("/countUserDeviceNum")
+    public R<Long> countUserDeviceNum() {
+        log.info("LifeUserController.countUserDeviceNum");
+        return R.data(lifeUserService.countUserDeviceNum());
+    }
+
 
 }

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

@@ -0,0 +1,20 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+
+/**
+ * 厂商推送统计查询结果
+ */
+@Data
+public class CommonPushChannelStatsDto {
+
+    private String expectedSend;
+
+    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);
+}

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

@@ -605,4 +605,12 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
         }
         return false;
     }
+
+    /**
+     * 统计用户设备总数(life_user.device_id 逗号拼接,按段计数求和)
+     */
+    public Long countUserDeviceNum() {
+        Long count = lifeUserMapper.countTotalDeviceNum();
+        return count == null ? 0L : count;
+    }
 }

+ 78 - 8
alien-store/src/main/java/shop/alien/store/service/channel/ApnsPushClient.java

@@ -143,14 +143,25 @@ public class ApnsPushClient {
             }
         }
         if (hasTokenCredential(credential)) {
-            ApnsSigningKey signingKey = ApnsSigningKey.loadFromInputStream(
-                    new ByteArrayInputStream(credential.getString("privateKey").getBytes(StandardCharsets.UTF_8)),
-                    credential.getString("teamId"),
-                    credential.getString("keyId"));
-            return new ApnsClientBuilder()
-                    .setApnsServer(apnsHost)
-                    .setSigningKey(signingKey)
-                    .build();
+            String pem = normalizePrivateKeyPem(credential.getString("privateKey"));
+            if (StringUtils.isBlank(pem)) {
+                log.error("APNs privateKey 为空或格式无法识别");
+                return null;
+            }
+            try {
+                ApnsSigningKey signingKey = ApnsSigningKey.loadFromInputStream(
+                        new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)),
+                        credential.getString("teamId"),
+                        credential.getString("keyId"));
+                return new ApnsClientBuilder()
+                        .setApnsServer(apnsHost)
+                        .setSigningKey(signingKey)
+                        .build();
+            } catch (Exception e) {
+                log.error("APNs privateKey 加载失败, teamId={}, keyId={}, err={}",
+                        credential.getString("teamId"), credential.getString("keyId"), e.getMessage(), e);
+                return null;
+            }
         }
         log.error("APNs 凭证不完整,需配置 p12Base64+p12Password(证书方式)或 teamId+keyId+privateKey(Token 方式)");
         return null;
@@ -223,6 +234,65 @@ public class ApnsPushClient {
         }
     }
 
+    /**
+     * 将 credential_json 中的 privateKey 规范为 PEM 多行格式,供 {@link ApnsSigningKey#loadFromInputStream} 使用。
+     * <p>支持:JSON 中 {@code \\n} 转义、整行无换行、标准多行 PEM。</p>
+     * <p>格式转换示例(单行 → 多行):</p>
+     * <pre>
+     * 转换前: -----BEGIN PRIVATE KEY-----MIGTAgEA...PCZ6m-----END PRIVATE KEY-----
+     * 转换后:
+     * -----BEGIN PRIVATE KEY-----
+     * MIGTAgEA...(每 64 字符换行)
+     * ...PCZ6m
+     * -----END PRIVATE KEY-----
+     * </pre>
+     */
+    private String normalizePrivateKeyPem(String rawKey) {
+        if (StringUtils.isBlank(rawKey)) {
+            return null;
+        }
+        String key = rawKey.trim()
+                .replace("\\r", "")
+                .replace("\\n", "\n");
+        if (key.contains("\n")) {
+            return key.endsWith("\n") ? key : key + "\n";
+        }
+        if (key.contains(PKCS8_BEGIN)) {
+            return reformatSingleLinePem(key, PKCS8_BEGIN, PKCS8_END);
+        }
+        if (key.contains(EC_PKCS8_BEGIN)) {
+            return reformatSingleLinePem(key, EC_PKCS8_BEGIN, EC_PKCS8_END);
+        }
+        log.warn("APNs privateKey 缺少 PEM 头尾标记 BEGIN/END PRIVATE KEY");
+        return key;
+    }
+
+    private static final String PKCS8_BEGIN = "-----BEGIN PRIVATE KEY-----";
+    private static final String PKCS8_END = "-----END PRIVATE KEY-----";
+    private static final String EC_PKCS8_BEGIN = "-----BEGIN EC PRIVATE KEY-----";
+    private static final String EC_PKCS8_END = "-----END EC PRIVATE KEY-----";
+
+    private String reformatSingleLinePem(String singleLine, String beginMarker, String endMarker) {
+        int beginIdx = singleLine.indexOf(beginMarker);
+        int endIdx = singleLine.indexOf(endMarker);
+        if (beginIdx < 0 || endIdx <= beginIdx) {
+            return singleLine;
+        }
+        String base64Body = singleLine.substring(beginIdx + beginMarker.length(), endIdx)
+                .replaceAll("\\s", "");
+        if (StringUtils.isBlank(base64Body)) {
+            log.warn("APNs privateKey PEM 主体为空");
+            return singleLine;
+        }
+        StringBuilder pem = new StringBuilder();
+        pem.append(beginMarker).append('\n');
+        for (int offset = 0; offset < base64Body.length(); offset += 64) {
+            pem.append(base64Body, offset, Math.min(offset + 64, base64Body.length())).append('\n');
+        }
+        pem.append(endMarker).append('\n');
+        return pem.toString();
+    }
+
     private void closeClient(ApnsClient client) {
         if (client == null) {
             return;

+ 505 - 125
alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java

@@ -1,9 +1,9 @@
 package shop.alien.store.service.channel;
 
 import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson2.JSONArray;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.codec.binary.Hex;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
@@ -17,19 +17,14 @@ 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;
 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;
+import java.util.*;
 
 /**
  * 各厂商推送 HTTP 客户端,凭证从配置表 credential_json 读取。
@@ -44,10 +39,16 @@ public class CommonPushVendorHttpClient {
     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";
+    /** 华为 OAuth 换 token(与推送 API 域名不同) */
+    private static final String HUAWEI_OAUTH_URL = "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
+    /** 华为 Push Kit 下行消息 API */
+    private static final String HUAWEI_PUSH_API_BASE = "https://push-api.cloud.huawei.com";
+    /** 荣耀 Push API */
+    private static final String HONOR_PUSH_API_BASE = "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 static final String HUAWEI_SUCCESS_CODE = "80000000";
 
     private final CommonPushProperties commonPushProperties;
     private final ApnsPushClient apnsPushClient;
@@ -57,185 +58,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();
+            }
+            String taskId = broadcastOppoMessage(authToken, messageId);
+            if (StringUtils.isBlank(taskId)) {
+                return CommonPushVendorSendResult.fail();
             }
-            return broadcastOppoMessage(authToken, messageId);
+            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 +263,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 +273,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 +362,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 +416,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 +494,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 +522,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 +599,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 +609,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,84 +743,177 @@ 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) {
+    /**
+     * 华为 Push Kit 批量/单条下发:OAuth 换 token → 组装 message body → POST messages:send。
+     * <p>格式转换 credential_json → 请求 URL:</p>
+     * <pre>
+     * 转换前 credential: { "clientId": "115095083", "clientSecret": "..." }
+     * 转换后 URL: https://push-api.cloud.huawei.com/v1/115095083/messages:send
+     * </pre>
+     */
+    private CommonPushVendorSendResult 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;
+            return CommonPushVendorSendResult.fail();
+        }
+        String pushClientId = resolveHuaweiOAuthClientId(credential);
+        if (StringUtils.isBlank(pushClientId)) {
+            log.warn("华为推送缺少 clientId/appId, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
+        }
+        List<String> pushTokens = normalizeHuaweiPushTokens(deviceIds);
+        if (pushTokens.isEmpty()) {
+            log.warn("华为推送 token 为空, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
+        }
+        for (String pushToken : pushTokens) {
+            if (!isLikelyHuaweiPushToken(pushToken)) {
+                log.warn("华为推送 token 格式可疑, taskNo={}, token={}, 请确认 life_user.device_id 存的是 HMS PushKit token 而非 uni-push cid 或其他厂商 regId",
+                        task.getTaskNo(), maskPushToken(pushToken));
+            }
         }
         String accessToken = obtainHuaweiAccessToken(credential);
         if (StringUtils.isBlank(accessToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
 
-        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        JSONObject body = buildHuaweiMessageBody(task, pushTokens);
         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;
+        // v1 路径参数为 OAuth 2.0 client ID,与换 token 的 client_id 一致
+        String sendUrl = HUAWEI_PUSH_API_BASE + "/v1/" + pushClientId + "/messages:send";
+        log.info("华为推送请求, taskNo={}, tokenCount={}, sendUrl={}, sampleToken={}",
+                task.getTaskNo(), pushTokens.size(), sendUrl, maskPushToken(pushTokens.get(0)));
+        String sendResp = postJson(sendUrl, body.toJSONString(), headers);
+        log.info("推送参数和结果为,accessToken={}, sendResp={}", accessToken, sendResp);
+        return parseHuaweiSendResult(sendResp, task.getTaskNo(), pushTokens.size());
     }
 
-    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();
+        }
+        List<String> pushTokens = normalizeHuaweiPushTokens(deviceIds);
+        if (pushTokens.isEmpty()) {
+            log.warn("荣耀推送 token 为空, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
         }
         String accessToken = obtainHonorAccessToken(credential);
         if (StringUtils.isBlank(accessToken)) {
-            return false;
+            return CommonPushVendorSendResult.fail();
         }
 
-        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        JSONObject body = buildHuaweiMessageBody(task, pushTokens);
         Map<String, String> headers = new HashMap<>();
         headers.put("Authorization", "Bearer " + accessToken);
-        String sendResp = postJson(HONOR_PUSH_BASE_URL + "/v1/" + appId + "/messages:send",
+        String sendResp = postJson(HONOR_PUSH_API_BASE + "/v1/" + appId + "/sendMessage",
                 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 parseHuaweiSendResult(sendResp, task.getTaskNo(), pushTokens.size());
+    }
+
+    /**
+     * 华为推送 API URL 中的 App ID(AppGallery Connect 应用 ID)。
+     */
+    private String resolveHuaweiPushAppId(JSONObject credential) {
+        String appId = StringUtils.trimToNull(credential.getString("appId"));
+        if (appId != null) {
+            return appId;
+        }
+        return StringUtils.trimToNull(credential.getString("clientId"));
+    }
+
+    /**
+     * OAuth client_id:优先 clientId,否则与 pushAppId 一致。
+     * 须与下发 URL 中的 appId 对应同一应用,否则返回 80200001。
+     */
+    private String resolveHuaweiOAuthClientId(JSONObject credential) {
+        String clientId = StringUtils.trimToNull(credential.getString("clientId"));
+        if (clientId != null) {
+            return clientId;
+        }
+        return resolveHuaweiPushAppId(credential);
+    }
+
+    private String resolveHuaweiOAuthClientSecret(JSONObject credential) {
+        String clientSecret = StringUtils.trimToNull(credential.getString("clientSecret"));
+        if (clientSecret != null) {
+            return clientSecret;
         }
-        return ok;
+        return StringUtils.trimToNull(credential.getString("appSecret"));
     }
 
+    /**
+     * 向华为 OAuth 服务换取 access_token(用于 Authorization: Bearer)。
+     * <p>格式转换 credential → form 参数:</p>
+     * <pre>
+     * 转换前 credential: { "clientId": "115095083", "clientSecret": "xxx" }
+     * 转换后 POST body: grant_type=client_credentials&client_id=115095083&client_secret=xxx
+     * 响应: { "access_token": "AT_xxx", "expires_in": 3600 }
+     * </pre>
+     */
     private String obtainHuaweiAccessToken(JSONObject credential) {
-        String appId = credential.getString("appId");
-        String appSecret = credential.getString("appSecret");
+        String clientId = resolveHuaweiOAuthClientId(credential);
+        String clientSecret = resolveHuaweiOAuthClientSecret(credential);
+        String pushAppId = resolveHuaweiPushAppId(credential);
+        if (StringUtils.isAnyBlank(clientId, clientSecret)) {
+            log.warn("华为 OAuth 凭证不完整, 需配置 appId+appSecret 或 clientId+clientSecret");
+            return null;
+        }
+        if (pushAppId != null && !StringUtils.equals(clientId, pushAppId)) {
+            log.warn("华为 OAuth clientId({}) 与 push appId({}) 不一致,若鉴权失败请改为同一应用 ID",
+                    clientId, pushAppId);
+        }
         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(HUAWEI_PUSH_BASE_URL + "/oauth2/v2/token", tokenForm, null);
-        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
-        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
-            log.warn("华为鉴权失败: {}", tokenResp);
+        tokenForm.add("client_id", clientId);
+        tokenForm.add("client_secret", clientSecret);
+        try {
+            String tokenResp = postForm(HUAWEI_OAUTH_URL, tokenForm, null);
+            JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+            if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+                log.warn("华为 OAuth 鉴权失败, clientId={}, resp={}", clientId, tokenResp);
+                return null;
+            }
+            return tokenJson.getString("access_token");
+        } catch (Exception e) {
+            log.error("华为 OAuth 请求异常, clientId={}, err={}", clientId, e.getMessage(), e);
             return null;
         }
-        return tokenJson.getString("access_token");
+    }
+
+    /**
+     * 解析华为 Push API 响应;成功码为 80000000。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前 JSON: { "code":"80000000", "requestId":"178169515953238426090801" }
+     * 转换后 CommonPushVendorSendResult: ok, vendorTaskId=requestId
+     * </pre>
+     */
+    private CommonPushVendorSendResult parseHuaweiSendResult(String resp, String taskNo, int deviceCount) {
+        JSONObject sendJson = JSONObject.parseObject(resp);
+        if (sendJson != null && HUAWEI_SUCCESS_CODE.equals(sendJson.getString("code"))) {
+            String requestId = sendJson.getString("requestId");
+            log.info("华为推送成功, taskNo={}, deviceCount={}, requestId={}", taskNo, deviceCount, requestId);
+            return CommonPushVendorSendResult.ok(requestId);
+        }
+        if (sendJson != null && "80300007".equals(sendJson.getString("code"))) {
+            log.error("华为推送 token 无效(80300007), taskNo={}, deviceCount={}, requestId={}, 请检查 token 是否由当前华为应用上报、是否过期、是否误用其他厂商或测试 token",
+                    taskNo, deviceCount, sendJson.getString("requestId"));
+        }
+        log.error("华为推送失败, taskNo={}, deviceCount={}, resp={}", taskNo, deviceCount, resp);
+        return CommonPushVendorSendResult.fail();
     }
 
     private String obtainHonorAccessToken(JSONObject credential) {
@@ -786,20 +932,117 @@ public class CommonPushVendorHttpClient {
         return tokenJson.getString("access_token");
     }
 
-    private JSONObject buildHuaweiMessageBody(CommonPushTask task, List<String> deviceIds) {
-        JSONObject body = new JSONObject();
+    /**
+     * 华为/荣耀下行消息体:token 必须与 android 同级,放在 message 对象内。
+     * <p>格式转换 pushTokens → 华为 API JSON body:</p>
+     * <pre>
+     * 转换前 List: ["HUAWEI_CN_IQAAAACy1la8..."]
+     * 转换后 body:
+     * {
+     *   "message": {
+     *     "android": { "notification": { "title":"...", "body":"...", "click_action": {"type":3} } },
+     *     "token": ["IQAAAACy1la8..."]
+     *   }
+     * }
+     * </pre>
+     *
+     * @see <a href="https://developer.huawei.com/consumer/en/doc/HMSCore-References/https-send-api-0000001050986197">Huawei Push send API</a>
+     */
+    private JSONObject buildHuaweiMessageBody(CommonPushTask task, List<String> pushTokens) {
         JSONObject message = new JSONObject();
         JSONObject android = new JSONObject();
         JSONObject notification = new JSONObject();
+        JSONObject clickAction = new JSONObject();
         notification.put("title", task.getTitle());
         notification.put("body", task.getContent());
+        clickAction.put("type", 3);
+        notification.put("click_action", clickAction);
         android.put("notification", notification);
         message.put("android", android);
+        List<String> huaweiTokens = new ArrayList<>();
+        for (String pushToken : pushTokens) {
+            String extracted = extractHuaweiPushToken(pushToken);
+            if (StringUtils.isNotBlank(extracted)) {
+                huaweiTokens.add(extracted);
+            }
+        }
+        message.put("token", new JSONArray(huaweiTokens));
+
+        JSONObject body = new JSONObject();
         body.put("message", message);
-        body.put("token", deviceIds.toArray(new String[0]));
         return body;
     }
 
+    /** 华为 Push Kit token 以 IQAAA 开头;客户端可能带 HUAWEI_CN_ 前缀。 */
+    private static final String HUAWEI_PUSH_TOKEN_MARKER = "IQAAA";
+
+    /**
+     * 剥离客户端上报的厂商前缀,得到华为 Push Kit 可用 token。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前: "HUAWEI_CN_IQAAAACy1la8AAC0PfgZ4PM9..."
+     * 转换后: "IQAAAACy1la8AAC0PfgZ4PM9..."
+     * </pre>
+     */
+    private String extractHuaweiPushToken(String rawToken) {
+        if (StringUtils.isBlank(rawToken)) {
+            return null;
+        }
+        String trimmed = rawToken.trim();
+        if (trimmed.startsWith(HUAWEI_PUSH_TOKEN_MARKER)) {
+            return trimmed;
+        }
+        int markerIndex = trimmed.indexOf(HUAWEI_PUSH_TOKEN_MARKER);
+        if (markerIndex >= 0) {
+            return trimmed.substring(markerIndex);
+        }
+        return trimmed;
+    }
+
+    private boolean isLikelyHuaweiPushToken(String token) {
+        String extracted = extractHuaweiPushToken(token);
+        return StringUtils.isNotBlank(extracted) && extracted.startsWith(HUAWEI_PUSH_TOKEN_MARKER);
+    }
+
+    private String maskPushToken(String token) {
+        if (StringUtils.isBlank(token)) {
+            return "";
+        }
+        String t = token.trim();
+        if (t.length() <= 12) {
+            return t.substring(0, Math.min(4, t.length())) + "***";
+        }
+        return t.substring(0, 8) + "..." + t.substring(t.length() - 4);
+    }
+
+    /**
+     * 过滤空白、去重并剥离 HUAWEI_CN_ 前缀,单批最多 1000 个 token。
+     * <p>格式转换 life_user.device_id 列表 → 华为 API token 列表(见 {@link #extractHuaweiPushToken})。</p>
+     */
+    private List<String> normalizeHuaweiPushTokens(List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> tokens = new ArrayList<>();
+        Set<String> exists = new HashSet<>();
+        for (String deviceId : deviceIds) {
+            if (StringUtils.isBlank(deviceId)) {
+                continue;
+            }
+            String token = extractHuaweiPushToken(deviceId);
+            if (StringUtils.isBlank(token)) {
+                continue;
+            }
+            if (exists.add(token)) {
+                tokens.add(token);
+            }
+        }
+        if (tokens.size() > HUAWEI_BATCH_TOKEN_LIMIT) {
+            return tokens.subList(0, HUAWEI_BATCH_TOKEN_LIMIT);
+        }
+        return tokens;
+    }
+
     private List<String> extractDeviceIds(List<CommonPushTargetDto> targets) {
         if (targets == null || targets.isEmpty()) {
             return Collections.emptyList();
@@ -829,12 +1072,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 +1103,142 @@ 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.setExpectedSend(longToString(status.getLong("resolved")));
+            stats.setRealSend(longToString(status.getLong("msg_send")));
+            stats.setRealDelivered(longToString(status.getLong("delivered")));
+            stats.setShowSum(longToString(status.getLong("msg_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();

+ 116 - 11
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;
@@ -45,6 +48,9 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
     private final CommonPushVendorHttpClient commonPushVendorHttpClient;
     private final CommonPushTaskUserService commonPushTaskUserService;
 
+    /**
+     * 查询当前可参与下发的厂商渠道(enable=1、凭证 JSON 合法、未超日配额)。
+     */
     @Override
     public List<CommonPushChannelConfig> listSendableChannels() {
         List<CommonPushChannelConfig> configs = commonPushChannelConfigMapper.selectList(
@@ -75,6 +81,16 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return result;
     }
 
+    /**
+     * 根据任务 targetType / targetConfig 解析推送目标设备列表。
+     * <p>格式转换 targetConfig JSON → 查询条件:</p>
+     * <pre>
+     * targetType=1: 查 life_user 全部 device_id 非空用户
+     * targetConfig={"userIds":[100,101]} → WHERE id IN (100,101) AND device_id 非空
+     * targetConfig={"deviceIds":["tokenA"]} → WHERE device_id IN ('tokenA')
+     * </pre>
+     * <p>输出 {@link CommonPushTargetDto} 列表,见 {@link #toTargetsFromUsers}。</p>
+     */
     @Override
     public List<CommonPushTargetDto> resolveTargets(CommonPushTask task) {
         if (task == null || task.getTargetType() == null) {
@@ -109,12 +125,16 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return Collections.emptyList();
     }
 
+    /** life_user 查询条件:仅包含已上报 device_id(厂商 Push Token)的用户。 */
     private LambdaQueryWrapper<LifeUser> deviceIdNotBlankWrapper() {
         return new LambdaQueryWrapper<LifeUser>()
                 .isNotNull(LifeUser::getDeviceId)
                 .ne(LifeUser::getDeviceId, "");
     }
 
+    /**
+     * 推送任务下发入口:解析目标 → 分组 → 调用各厂商 API。
+     */
     @Override
     public CommonPushSendResultDto send(CommonPushTask task) {
         List<CommonPushTargetDto> targets = resolveTargets(task);
@@ -159,6 +179,14 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return task;
     }
 
+    /**
+     * 向目标设备列表执行多渠道推送(全量广播 + 单推/批量推)。
+     * <p>格式转换 channelMap:</p>
+     * <pre>
+     * 转换前 List&lt;CommonPushChannelConfig&gt;: [{channelCode:"huawei"}, {channelCode:"oppo"}]
+     * 转换后 Map: { "huawei" -> config, "oppo" -> config }
+     * </pre>
+     */
     private CommonPushSendResultDto sendToTargets(CommonPushTask task, List<CommonPushTargetDto> targets) {
         CommonPushSendResultDto result = new CommonPushSendResultDto();
         List<CommonPushChannelConfig> channels = listSendableChannels();
@@ -185,21 +213,31 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
 
         int sentCount = 0;
         int failedCount = 0;
+        Map<String, String> vendorTaskIds = new LinkedHashMap<>();
         Set<String> broadcastHandledChannels = new HashSet<>();
+        // targetType=1 全量:对 apns/huawei/honor/xiaomi/oppo/vivo 依次走厂商广播或批量 API,与后续单推互斥
         if (fullBroadcast) {
             for (String channelCode : FULL_BROADCAST_CHANNELS) {
                 if (!channelMap.containsKey(channelCode)) {
                     continue;
                 }
+                // 标记该渠道已由广播处理,后续 grouped 单推循环会 skip,避免重复下发
                 broadcastHandledChannels.add(channelCode);
                 CommonPushChannelConfig channelConfig = channelMap.get(channelCode);
+                // 本渠道在 groupTargetsByChannel 中分到的设备;无匹配时为空列表(部分厂商仍支持无 token 广播)
                 List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+                // APNs 需从全量 targets 中筛 iOS deviceToken;其他厂商直接用 channelTargets
                 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);
+                // 调用对应厂商全量/广播接口(如 OPPO broadcast、华为按 token 批量)
+                CommonPushVendorSendResult sendResult = invokeFullBroadcast(channelCode, channelConfig, task, broadcastTargets);
+                // 成功时写入 vendorTaskIds,格式:huawei_task_id -> requestId
+                CommonPushVendorTaskIdUtil.putTaskId(vendorTaskIds, channelCode, sendResult);
+                // 累加 sentCount、更新 sentTargets,并更新渠道 today_usage
+                sentCount += applyFullBroadcastResult(sendResult.isSuccess(), channelCode, grouped, task, result, channelConfig);
+                // 广播失败时累加 failedCount(无设备时分母按 1 计)
+                failedCount += countFullBroadcastFailure(sendResult.isSuccess(), channelCode, grouped);
             }
         }
 
@@ -214,23 +252,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);
@@ -242,10 +282,15 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return result;
     }
 
+    /** estimatedCount 为空或 ≤0 时广播成功计数按 1 计。 */
     private int defaultCount(Integer count) {
         return count == null || count <= 0 ? 1 : count;
     }
 
+    /**
+     * 全量广播渠道推送成功后的计数与结果汇总。
+     * <p>无具体设备时 sentCount 取 task.estimatedCount(默认至少 1)。</p>
+     */
     private int applyFullBroadcastResult(boolean ok, String channelCode,
                                          Map<String, List<CommonPushTargetDto>> grouped,
                                          CommonPushTask task, CommonPushSendResultDto result,
@@ -272,7 +317,8 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return 0;
     }
 
-    private boolean invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
+    /** 按渠道编码调用对应厂商全量/广播推送 API(apns/huawei/honor/xiaomi/oppo/vivo)。 */
+    private CommonPushVendorSendResult invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
                                           List<CommonPushTargetDto> channelTargets) {
         try {
             switch (channelCode) {
@@ -289,14 +335,15 @@ 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();
         }
     }
 
+    /** 更新渠道 today_usage,失败仅打日志不中断推送。 */
     private void updateChannelUsageSafely(CommonPushChannelConfig config, String channelCode) {
         try {
             updateChannelUsage(config);
@@ -305,6 +352,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         }
     }
 
+    /** 全量广播失败时的失败条数(无设备时分母按 1 计)。 */
     private int countFullBroadcastFailure(boolean ok, String channelCode,
                                           Map<String, List<CommonPushTargetDto>> grouped) {
         if (ok) {
@@ -314,6 +362,15 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return channelTargets.isEmpty() ? 1 : channelTargets.size();
     }
 
+    /**
+     * 推送完成后写入 common_push_task_user 发送记录。
+     * <p>格式转换发送状态:</p>
+     * <pre>
+     * 转换前: target 在 sentTargets 中 → status=2(已发送)
+     *         target 仅在 failedTargets 中 → status=0(失败)
+     *         broadcast 占位目标(无 deviceId)→ 单独一条 status=2 记录
+     * </pre>
+     */
     @Override
     public void saveTaskUserRecords(Long pushTaskId, CommonPushSendResultDto sendResult) {
         if (pushTaskId == null || sendResult == null) {
@@ -353,6 +410,15 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         }
     }
 
+    /**
+     * 按 targetConfig.deviceIds 解析目标(去重、trim 后查 life_user)。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前 JSONArray: [" HUAWEI_CN_IQAAA... ", "token2"]
+     * 转换后 List&lt;String&gt;: ["HUAWEI_CN_IQAAA...", "token2"]
+     * 再经 toTargetsFromUsers → List&lt;CommonPushTargetDto&gt;
+     * </pre>
+     */
     private List<CommonPushTargetDto> buildTargetsByDeviceIds(JSONArray deviceIds) {
         if (deviceIds == null || deviceIds.isEmpty()) {
             return Collections.emptyList();
@@ -373,6 +439,15 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return toTargetsFromUsers(users);
     }
 
+    /**
+     * 将 life_user 转为推送目标 DTO。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前 LifeUser: { id: 100, deviceId: "HUAWEI_CN_IQAAA..." }
+     * 转换后 CommonPushTargetDto: { userId: 100L, deviceId: "HUAWEI_CN_IQAAA...", platform: "android" }
+     * </pre>
+     * <p>华为 token 前缀剥离在 {@link CommonPushVendorHttpClient} 下发时处理。</p>
+     */
     private List<CommonPushTargetDto> toTargetsFromUsers(List<LifeUser> users) {
         if (users == null || users.isEmpty()) {
             return Collections.emptyList();
@@ -385,12 +460,34 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
             CommonPushTargetDto target = new CommonPushTargetDto();
             target.setUserId(user.getId() != null ? user.getId().longValue() : null);
             target.setDeviceId(user.getDeviceId());
-            target.setPlatform("android");
+            // TODO 这个 platform 是根据 deviceId 自动判断的,这里先默认 android 了?怎么判断是 android 还是 ios?
+            if (isIOSDeviceToken(user.getDeviceId())) {
+                target.setPlatform("ios");
+            } else {
+                target.setPlatform("android");
+            }
             targets.add(target);
         }
         return targets;
     }
 
+    private boolean isIOSDeviceToken(String deviceId) {
+        if (deviceId == null || deviceId.length() != 64) {
+            return false;
+        }
+        // 正则:仅0-9、a-f小写
+        return deviceId.matches("[0-9a-f]{64}");
+    }
+
+    /**
+     * 按厂商渠道对推送目标分组,供后续逐渠道调用 API。
+     * <p>格式转换 targetConfig → preferredChannel:</p>
+     * <pre>
+     * 转换前 String: "{\"vendorChannel\":\"huawei\"}"
+     * 转换后 String: "huawei"(小写;未配置则为 null,走 platform 自动选择)
+     * </pre>
+     * <p>输出示例:{ "huawei" -> [target1, target2], "apns" -> [target3] }</p>
+     */
     private Map<String, List<CommonPushTargetDto>> groupTargetsByChannel(List<CommonPushTargetDto> targets,
                                                                          Map<String, CommonPushChannelConfig> channelMap,
                                                                          String targetConfigJson) {
@@ -412,6 +509,11 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return grouped;
     }
 
+    /**
+     * 为单个目标解析厂商渠道编码。
+     * <p>优先级:targetConfig.vendorChannel → iOS 用 apns → Android 用 channelMap 中优先级最高渠道。</p>
+     * <p>Android 优先级:huawei(1) &gt; honor(2) &gt; xiaomi(3) &gt; oppo(4) &gt; vivo(5) &gt; samsung(6)</p>
+     */
     private String resolveChannelCode(CommonPushTargetDto target,
                                       Map<String, CommonPushChannelConfig> channelMap,
                                       String preferredChannel) {
@@ -429,6 +531,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return androidChannels.isEmpty() ? null : androidChannels.get(0);
     }
 
+    /** Android 厂商渠道自动选择时的排序权重(越小越优先)。 */
     private int channelPriority(String channelCode) {
         switch (channelCode) {
             case "huawei":
@@ -455,6 +558,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return StringUtils.lowerCase(platform.trim());
     }
 
+    /** 判断渠道是否已达 daily_quota 上限(0 表示不限)。 */
     private boolean isQuotaExceeded(CommonPushChannelConfig config) {
         if (config.getDailyQuota() == null || config.getDailyQuota() <= 0) {
             return false;
@@ -463,6 +567,7 @@ public class CommonPushSendServiceImpl implements CommonPushSendService {
         return usage >= config.getDailyQuota();
     }
 
+    /** 单推成功后 today_usage +1 并写回 common_push_channel_config。 */
     private void updateChannelUsage(CommonPushChannelConfig config) {
         int usage = config.getTodayUsage() == null ? 0 : config.getTodayUsage();
         config.setTodayUsage(usage + 1);

+ 64 - 3
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,7 +48,12 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
     private final CommonPushSendService commonPushSendService;
     private final CommonPushReviewService commonPushReviewService;
     private final AiContentModerationUtil aiContentModerationUtil;
+    private final CommonPushTaskStatsService commonPushTaskStatsService;
 
+    /**
+     * 新增推送任务:校验 → 填默认值 → AI 审核 → 入库 → 按 sendType 决定是否立即下发。
+     * <p>sendType=1 立即发送时调用 {@link #doSendTask};sendType=2 仅入库等待定时任务。</p>
+     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public R<String> add(CommonPushTask task) {
@@ -143,6 +150,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)
@@ -163,6 +175,15 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         }
     }
 
+    /**
+     * 执行推送并回写任务状态。
+     * <p>成功:status=2(已发送),回填 actualCount、vendorTaskIds;失败:status=4。</p>
+     * <p>格式转换:vendorTaskIds Map → 字符串存入 common_push_task.vendor_task_ids</p>
+     * <pre>
+     * 转换前 Map: { "huawei_task_id": "178169515953238426090801" }
+     * 转换后 String: "huawei_task_id: 178169515953238426090801"
+     * </pre>
+     */
     private R<String> doSendTask(CommonPushTask task) {
         CommonPushSendResultDto sendResult;
         try {
@@ -180,11 +201,14 @@ 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());
     }
 
+    /** 将 Date 格式化为界面展示用字符串 yyyy-MM-dd HH:mm:ss(定时任务提示文案)。 */
     private String formatDateTime(Date date) {
         if (date == null) {
             return "";
@@ -208,6 +232,11 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         return result.isSuccess() ? R.data(result, result.getMessage()) : R.fail(result.getMessage());
     }
 
+    /**
+     * 新增任务参数校验。
+     *
+     * @return null 表示通过;非 null 为 R.fail 可直接返回
+     */
     private R<String> validateTaskForAdd(CommonPushTask task) {
         if (task == null) {
             return R.fail("任务不能为空");
@@ -242,7 +271,9 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
     }
 
     /**
-     * @return 审核驳回原因,通过时返回 null
+     * AI 审核推送标题与正文(敏感词/违规内容)。
+     *
+     * @return 驳回原因;通过时返回 null
      */
     private String auditPushContent(CommonPushTask task) {
         AiContentModerationUtil.AuditResult titleAudit =
@@ -260,6 +291,7 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         return null;
     }
 
+    /** 写入 common_push_review 审核记录(通过/驳回及原因)。 */
     private void savePushReview(Long pushTaskId, boolean auditPassed, String rejectReason) {
         CommonPushReview review = new CommonPushReview();
         review.setPushTaskId(pushTaskId);
@@ -275,6 +307,14 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         commonPushReviewService.save(review);
     }
 
+    /**
+     * 填充任务默认值(未传字段时)。
+     * <p>格式转换 taskNo:</p>
+     * <pre>
+     * 转换前: taskNo = null
+     * 转换后: taskNo = "TS202606171916012942"  (TS + yyyyMMddHHmmss + 4位随机数)
+     * </pre>
+     */
     private void fillTaskDefaults(CommonPushTask task) {
         if (StringUtils.isBlank(task.getTaskNo())) {
             task.setTaskNo("TS" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())
@@ -339,10 +379,12 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
                                                             Date startTime, Date endTime) {
         log.info("CommonPushTaskServiceImpl.statisticsList, pageNum={}, pageSize={}, taskNo={}, keyword={}, status={}, pushType={}, channel={}, startTime={}, endTime={}",
                 pageNum, pageSize, taskNo, keyword, status, pushType, channel, startTime, endTime);
+        Date queryStartTime = normalizeQueryStartTime(startTime);
+        Date queryEndTime = normalizeQueryEndTime(endTime);
         Page<CommonPushTaskStatsDto> page = new Page<>(pageNum, pageSize);
-        IPage<CommonPushTaskStatsDto> statsPage = baseMapper.selectStatisticsPage(page, taskNo, keyword, status, pushType, channel, startTime, endTime);
+        IPage<CommonPushTaskStatsDto> statsPage = baseMapper.selectStatisticsPage(page, taskNo, keyword, status, pushType, channel, queryStartTime, queryEndTime);
 
-        CommonPushTaskStatsDto summaryStats = baseMapper.selectStatisticsSummary(taskNo, keyword, status, pushType, channel, startTime, endTime);
+        CommonPushTaskStatsDto summaryStats = baseMapper.selectStatisticsSummary(taskNo, keyword, status, pushType, channel, queryStartTime, queryEndTime);
 
         CommonPushTaskStatisticsPageVo result = new CommonPushTaskStatisticsPageVo();
         result.setSummary(buildUserStatsSummary(summaryStats != null ? summaryStats : emptyUserStats()));
@@ -548,6 +590,25 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
         return calendar.getTime();
     }
 
+    /** 查询开始时间归一化到当天 00:00:00 */
+    private Date normalizeQueryStartTime(Date startTime) {
+        return startTime == null ? null : truncateToDay(startTime);
+    }
+
+    /** 查询结束时间归一化到当天 23:59:59.999,传日期时可包含当天全天数据 */
+    private Date normalizeQueryEndTime(Date endTime) {
+        if (endTime == null) {
+            return null;
+        }
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(endTime);
+        calendar.set(Calendar.HOUR_OF_DAY, 23);
+        calendar.set(Calendar.MINUTE, 59);
+        calendar.set(Calendar.SECOND, 59);
+        calendar.set(Calendar.MILLISECOND, 999);
+        return calendar.getTime();
+    }
+
     private long defaultCount(Long count) {
         return count == null ? 0L : count;
     }

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

@@ -0,0 +1,279 @@
+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.entity.store.dto.CommonPushTaskStatsDto;
+import shop.alien.mapper.CommonPushChannelConfigMapper;
+import shop.alien.mapper.CommonPushTaskMapper;
+import shop.alien.mapper.CommonPushTaskUserMapper;
+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 CommonPushTaskUserMapper commonPushTaskUserMapper;
+    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(), task.getId(), phoneType);
+            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,
+                                                          Long pushTaskId, String phoneType) {
+        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);
+            case "huawei":
+                return queryHuaweiStatistics(pushTaskId, phoneType);
+            case "honor":
+                return queryHonorStatistics(pushTaskId, phoneType);
+            case "samsung":
+                return querySamsungStatistics(pushTaskId, phoneType);
+            case "apns":
+                return queryApnsStatistics(pushTaskId, phoneType);
+            default:
+                log.debug("渠道暂无统计查询接口, channelCode={}, taskId={}", channelCode, firstTaskId);
+                return null;
+        }
+    }
+
+    /**
+     * 华为无厂商侧统计拉取 API,基于 common_push_task_user 回调数据聚合。
+     */
+    private CommonPushChannelStatsDto queryHuaweiStatistics(Long pushTaskId, String phoneType) {
+        return aggregateCallbackStatistics(pushTaskId, phoneType, "huawei");
+    }
+
+    private CommonPushChannelStatsDto queryHonorStatistics(Long pushTaskId, String phoneType) {
+        return aggregateCallbackStatistics(pushTaskId, phoneType, "honor");
+    }
+
+    private CommonPushChannelStatsDto querySamsungStatistics(Long pushTaskId, String phoneType) {
+        return aggregateCallbackStatistics(pushTaskId, phoneType, "samsung");
+    }
+
+    private CommonPushChannelStatsDto queryApnsStatistics(Long pushTaskId, String phoneType) {
+        return aggregateCallbackStatistics(pushTaskId, phoneType, "apns");
+    }
+
+    private CommonPushChannelStatsDto aggregateCallbackStatistics(Long pushTaskId, String phoneType,
+                                                                    String channelCode) {
+        if (pushTaskId == null) {
+            return null;
+        }
+        CommonPushTaskStatsDto stats = commonPushTaskUserMapper.selectStatsByTaskAndPhoneType(pushTaskId, phoneType);
+        if (isZeroStats(stats) && StringUtils.isNotBlank(phoneType)) {
+            stats = commonPushTaskUserMapper.selectStatsByTaskAndPhoneType(pushTaskId, null);
+        }
+        if (stats == null || isZeroStats(stats)) {
+            log.debug("回调统计无数据, channelCode={}, pushTaskId={}, phoneType={}", channelCode, pushTaskId, phoneType);
+            return null;
+        }
+        CommonPushChannelStatsDto result = new CommonPushChannelStatsDto();
+        result.setRealSend(longToString(stats.getSentCount()));
+        result.setRealDelivered(longToString(stats.getDeliveredCount()));
+        result.setShowSum(longToString(stats.getShowCount()));
+        result.setClickSum(longToString(stats.getClickCount()));
+        log.info("回调统计聚合完成, channelCode={}, pushTaskId={}, phoneType={}, stats={}",
+                channelCode, pushTaskId, phoneType, result);
+        return result;
+    }
+
+    private boolean isZeroStats(CommonPushTaskStatsDto stats) {
+        return longValue(stats.getSentCount()) == 0
+                && longValue(stats.getDeliveredCount()) == 0
+                && longValue(stats.getShowCount()) == 0
+                && longValue(stats.getClickCount()) == 0;
+    }
+
+    private long longValue(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    private String longToString(Long value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    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.setExpectedSend(stats.getExpectedSend());
+        taskNum.setRealSend(stats.getRealSend());
+        taskNum.setRealDelivered(stats.getRealDelivered());
+        taskNum.setClickSum(stats.getClickSum());
+        taskNum.setShowSum(stats.getShowSum());
+    }
+
+    /**
+     * 推送成功后按厂商初始化 common_push_task_num 统计行。
+     * <p>格式转换 vendorTaskIds key → phoneType:</p>
+     * <pre>
+     * 转换前 Map key: "huawei_task_id"
+     * 转换后 phoneType: "3"(见 CommonPushPhoneType.HUAWEI)
+     * expectedSend: task.estimatedCount 转字符串,如 "1000"
+     * </pre>
+     */
+    @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());
+    }
+}

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

@@ -0,0 +1,109 @@
+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() {
+    }
+
+    /**
+     * 将各厂商 task_id Map 格式化为入库字符串(common_push_task.vendor_task_ids)。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前 Map: { "huawei_task_id": "178169515953238426090801", "oppo_message_id": "msg123" }
+     * 转换后 String: "huawei_task_id: 178169515953238426090801, oppo_message_id: msg123"
+     * </pre>
+     */
+    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;
+    }
+
+    /**
+     * 广播/批量推送成功后写入厂商 task_id(key 规则:{channelCode}_task_id)。
+     * <p>格式转换:</p>
+     * <pre>
+     * 转换前: channelCode="huawei", sendResult.vendorTaskId="178169515953238426090801"
+     * 转换后 Map 条目: huawei_task_id -> 178169515953238426090801
+     * </pre>
+     */
+    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());
+        }
+    }
+
+    /** 单推成功时合并厂商 task_id(同渠道多次推送用逗号拼接)。 */
+    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());
+        }
+    }
+}