Explorar o código

Merge branch 'sit' into sit-logout

# Conflicts:
#	alien-entity/src/main/java/shop/alien/entity/store/LifeUser.java
#	alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java
lutong hai 19 horas
pai
achega
66a0510777
Modificáronse 68 ficheiros con 6878 adicións e 36 borrados
  1. 105 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushChannelConfig.java
  2. 75 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushReview.java
  3. 156 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTask.java
  4. 78 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskNum.java
  5. 25 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskStatus.java
  6. 82 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskUser.java
  7. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/LifeUser.java
  8. 8 8
      alien-entity/src/main/java/shop/alien/entity/store/StoreInfoDraft.java
  9. 36 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/CommonPushTaskStatsDto.java
  10. 32 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushFunnelStepVo.java
  11. 26 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushFunnelVo.java
  12. 38 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushReportTopItemVo.java
  13. 46 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushReportVo.java
  14. 38 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushStatisticsSummaryVo.java
  15. 65 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushTaskStatisticsItemVo.java
  16. 26 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushTaskStatisticsPageVo.java
  17. 7 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushChannelConfigMapper.java
  18. 7 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushReviewMapper.java
  19. 115 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskMapper.java
  20. 7 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskNumMapper.java
  21. 23 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskUserMapper.java
  22. 9 0
      alien-entity/src/main/java/shop/alien/mapper/LifeUserMapper.java
  23. 13 1
      alien-gateway/src/main/java/shop/alien/gateway/controller/LifeUserController.java
  24. 32 0
      alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java
  25. 16 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  26. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushScheduledJob.java
  27. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushTaskStatsJob.java
  28. 7 0
      alien-store/pom.xml
  29. 21 0
      alien-store/src/main/java/shop/alien/store/config/CommonPushProperties.java
  30. 99 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushChannelConfigController.java
  31. 76 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushReviewController.java
  32. 158 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java
  33. 42 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java
  34. 77 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskNumController.java
  35. 112 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskUserController.java
  36. 8 0
      alien-store/src/main/java/shop/alien/store/controller/LifeUserController.java
  37. 20 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushChannelStatsDto.java
  38. 30 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java
  39. 21 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTargetDto.java
  40. 52 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTaskUserCallbackDto.java
  41. 31 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTestSendDto.java
  42. 37 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushVendorSendResult.java
  43. 23 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushChannelConfigService.java
  44. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushReviewService.java
  45. 37 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushSendService.java
  46. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskNumService.java
  47. 57 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java
  48. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskStatsService.java
  49. 27 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskUserService.java
  50. 8 0
      alien-store/src/main/java/shop/alien/store/service/LifeUserService.java
  51. 1 1
      alien-store/src/main/java/shop/alien/store/service/StoreCommentAppealService.java
  52. 349 0
      alien-store/src/main/java/shop/alien/store/service/channel/ApnsPushClient.java
  53. 1284 0
      alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java
  54. 153 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushChannelConfigServiceImpl.java
  55. 65 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushReviewServiceImpl.java
  56. 576 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java
  57. 66 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskNumServiceImpl.java
  58. 624 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java
  59. 279 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskStatsServiceImpl.java
  60. 304 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskUserServiceImpl.java
  61. 26 26
      alien-store/src/main/java/shop/alien/store/service/impl/StoreCommentAppealServiceImpl.java
  62. 51 0
      alien-store/src/main/java/shop/alien/store/util/CommonPushPhoneType.java
  63. 109 0
      alien-store/src/main/java/shop/alien/store/util/CommonPushVendorTaskIdUtil.java
  64. 387 0
      docs/devops/dev/Introduction.md
  65. 244 0
      docs/devops/dev/java/Jenkinsfile
  66. 133 0
      docs/devops/dev/java/docker-compose.yml
  67. 69 0
      docs/devops/dev/middleware/docker-compose.yml
  68. 13 0
      docs/devops/dev/middleware/redis-dev/conf/redis.conf

+ 105 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushChannelConfig.java

@@ -0,0 +1,105 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 推送渠道配置表
+ */
+@Data
+@JsonInclude
+@TableName("common_push_channel_config")
+@ApiModel(value = "CommonPushChannelConfig", description = "推送渠道配置表")
+public class CommonPushChannelConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("渠道编码,唯一:apns/huawei/xiaomi/oppo/vivo/honor")
+    @TableField("channel_code")
+    private String channelCode;
+
+    @ApiModelProperty("渠道显示名称")
+    @TableField("channel_name")
+    private String channelName;
+
+    @ApiModelProperty("平台:iOS/Android")
+    @TableField("platform")
+    private String platform;
+
+    @ApiModelProperty("渠道开关:0-禁用 1-启用")
+    @TableField("enable")
+    private Integer enable;
+
+    @ApiModelProperty("并发优先级:P1/P2/P3,数字越小优先级越高")
+    @TableField("priority_level")
+    private String priorityLevel;
+
+    @ApiModelProperty("每日发送配额上限,0表示不限")
+    @TableField("daily_quota")
+    private Integer dailyQuota;
+
+    @ApiModelProperty("厂商凭证JSON(BundleID、TeamID、AuthKey等)")
+    @TableField("credential_json")
+    private String credentialJson;
+
+    @ApiModelProperty("今日已用推送数")
+    @TableField("today_usage")
+    private Integer todayUsage;
+
+    @ApiModelProperty("送达成功率百分比")
+    @TableField("success_rate")
+    private BigDecimal successRate;
+
+    @ApiModelProperty("证书过期时间(仅APNs)")
+    @TableField("cert_expire_time")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date certExpireTime;
+
+    @ApiModelProperty("最近连通性测试时间")
+    @TableField("last_test_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date lastTestTime;
+
+    @ApiModelProperty("在线状态:0-离线 1-在线")
+    @TableField("connect_status")
+    private Integer connectStatus;
+
+    @ApiModelProperty("告警信息(如配额预警)")
+    @TableField("warn_msg")
+    private String warnMsg;
+
+    @ApiModelProperty("删除标记,0:未删除,1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty("修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

+ 75 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushReview.java

@@ -0,0 +1,75 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 推送审核表
+ */
+@Data
+@JsonInclude
+@TableName("common_push_review")
+@ApiModel(value = "CommonPushReview", description = "推送审核表")
+public class CommonPushReview implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("关联推送主表 common_push_task.id")
+    @TableField("push_task_id")
+    private Long pushTaskId;
+
+    @ApiModelProperty("审核状态:0-草稿/待提交 1-待审核 2-审核通过 3-审核驳回")
+    @TableField("review_status")
+    private Integer reviewStatus;
+
+    @ApiModelProperty("任务驳回次数,达到3次不可再次提交")
+    @TableField("reject_count")
+    private Integer rejectCount;
+
+    @ApiModelProperty("审核驳回原因")
+    @TableField("reject_reason")
+    private String rejectReason;
+
+    @ApiModelProperty("审核人用户ID")
+    @TableField("review_by")
+    private Long reviewBy;
+
+    @ApiModelProperty("审核操作时间")
+    @TableField("review_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date reviewAt;
+
+    @ApiModelProperty("删除标记,0:未删除,1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty("修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

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

@@ -0,0 +1,156 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 推送任务表
+ */
+@Data
+@JsonInclude
+@TableName("common_push_task")
+@ApiModel(value = "CommonPushTask", description = "推送任务表")
+public class CommonPushTask implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("任务编号,全局唯一,自动生成")
+    @TableField("task_no")
+    private String taskNo;
+
+    @ApiModelProperty("推送标题")
+    @TableField("title")
+    private String title;
+
+    @ApiModelProperty("推送正文内容")
+    @TableField("content")
+    private String content;
+
+    @ApiModelProperty("推送类型:1-交易类 2-系统类 3-运营类 4-社交类")
+    @TableField("push_type")
+    private Integer pushType;
+
+    @ApiModelProperty("所选渠道数组,如 [\"notification\",\"inapp\"]")
+    @TableField("channels")
+    private String channels;
+
+    @ApiModelProperty("通知栏样式:1-文本 2-大图 3-大文本 4-收件箱")
+    @TableField("notification_style")
+    private Integer notificationStyle;
+
+    @ApiModelProperty("应用内类型:1-Banner 2-弹窗 3-卡片 4-全屏大图")
+    @TableField("inapp_type")
+    private Integer inappType;
+
+    @ApiModelProperty("推送图片OSS地址")
+    @TableField("image_url")
+    private String imageUrl;
+
+    @ApiModelProperty("跳转方式:1-首页 2-Deeplink 3-URL")
+    @TableField("jump_type")
+    private Integer jumpType;
+
+    @ApiModelProperty("跳转链接")
+    @TableField("jump_url")
+    private String jumpUrl;
+
+    @ApiModelProperty("附加键值参数JSON")
+    @TableField("extra_params")
+    private String extraParams;
+
+    @ApiModelProperty("推送声音:default/silent/自定义声音名")
+    @TableField("sound")
+    private String sound;
+
+    @ApiModelProperty("目标类型:1-全量 2-分组 3-标签 4-导入 5-条件")
+    @TableField("target_type")
+    private Integer targetType;
+
+    @ApiModelProperty("目标配置JSON:分组ID/标签ID/表达式等")
+    @TableField("target_config")
+    private String targetConfig;
+
+    @ApiModelProperty("创建时预估目标用户数")
+    @TableField("estimated_count")
+    private Integer estimatedCount;
+
+    @ApiModelProperty("实际发送人数,完成后回填")
+    @TableField("actual_count")
+    private Integer actualCount;
+
+    @ApiModelProperty("发送方式:1-立即 2-定时 3-周期 4-事件触发")
+    @TableField("send_type")
+    private Integer sendType;
+
+    @ApiModelProperty("定时执行时间")
+    @TableField("scheduled_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date scheduledAt;
+
+    @ApiModelProperty("周期推送Cron表达式")
+    @TableField("cron_expr")
+    private String cronExpr;
+
+    @ApiModelProperty("定时推送过期时间")
+    @TableField("expire_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date expireAt;
+
+    @ApiModelProperty("智能时机:0-否 1-是")
+    @TableField("smart_timing")
+    private Integer smartTiming;
+
+    @ApiModelProperty("优先级:1-普通 2-高 3-紧急")
+    @TableField("priority")
+    private Integer priority;
+
+    @ApiModelProperty("任务状态:0-草稿 1-待审核 2-已发送 3-已送达 4-失败 5-审核通过 6-驳回")
+    @TableField("status")
+    private Integer status;
+
+    @ApiModelProperty("运营内部备注")
+    @TableField("remark")
+    private String remark;
+
+    @ApiModelProperty("关联消息模板ID")
+    @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
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty("修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

+ 78 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskNum.java

@@ -0,0 +1,78 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 推送任务数量统计表(按机型)
+ */
+@Data
+@JsonInclude
+@TableName("common_push_task_num")
+@ApiModel(value = "CommonPushTaskNum", description = "推送任务数量统计表")
+public class CommonPushTaskNum implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("活动ID,关联 common_push_task.id")
+    @TableField("push_task_id")
+    private Long pushTaskId;
+
+    @ApiModelProperty("机型:1-OPPO 2-VIVO 3-华为 4-苹果 5-荣耀 6-小米 7-三星")
+    @TableField("phone_type")
+    private String phoneType;
+
+    @ApiModelProperty("预计发送数")
+    @TableField("expected_send")
+    private String expectedSend;
+
+    @ApiModelProperty("实际发送数")
+    @TableField("real_send")
+    private String realSend;
+
+    @ApiModelProperty("实际送达数")
+    @TableField("real_delivered")
+    private String realDelivered;
+
+    @ApiModelProperty("点击数")
+    @TableField("click_sum")
+    private String clickSum;
+
+    @ApiModelProperty("展示数")
+    @TableField("show_sum")
+    private String showSum;
+
+    @ApiModelProperty("删除标记,0:未删除,1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty("修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

+ 25 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskStatus.java

@@ -0,0 +1,25 @@
+package shop.alien.entity.store;
+
+/**
+ * common_push_task.status 状态枚举
+ */
+public final class CommonPushTaskStatus {
+
+    /** 草稿 */
+    public static final int DRAFT = 0;
+    /** 待审核 */
+    public static final int PENDING_REVIEW = 1;
+    /** 已发送 */
+    public static final int SENT = 2;
+    /** 已送达 */
+    public static final int DELIVERED = 3;
+    /** 失败 */
+    public static final int FAILED = 4;
+    /** 审核通过 */
+    public static final int REVIEW_PASSED = 5;
+    /** 驳回 */
+    public static final int REJECTED = 6;
+
+    private CommonPushTaskStatus() {
+    }
+}

+ 82 - 0
alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskUser.java

@@ -0,0 +1,82 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 推送任务用户关联表
+ */
+@Data
+@JsonInclude
+@TableName("common_push_task_user")
+@ApiModel(value = "CommonPushTaskUser", description = "推送任务用户关联表")
+public class CommonPushTaskUser implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("关联推送主表 common_push_task.id")
+    @TableField("push_task_id")
+    private Long pushTaskId;
+
+    @ApiModelProperty("关联系统用户ID(允许重复,同一用户可多设备)")
+    @TableField("user_id")
+    private Long userId;
+
+    @ApiModelProperty("设备唯一标识ID")
+    @TableField("device_id")
+    private String deviceId;
+
+    @ApiModelProperty("手机设备类型")
+    @TableField("phone_type")
+    private String phoneType;
+
+    @ApiModelProperty("关联类型:1-接收用户 2-协办人 3-权限查看人")
+    @TableField("rel_type")
+    private Integer relType;
+
+    @ApiModelProperty("发送状态:2-已发送 3-已抵达设备")
+    @TableField("status")
+    private Integer status;
+
+    @ApiModelProperty("展示状态:0-未展示 1-已展示")
+    @TableField("show_info")
+    private Integer showInfo;
+
+    @ApiModelProperty("点击状态:0-未点击 1-已点击")
+    @TableField("user_add")
+    private Integer userAdd;
+
+    @ApiModelProperty("删除标记,0:未删除,1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty("创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty("修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

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

@@ -205,4 +205,8 @@ public class LifeUser implements Serializable {
     @TableField("logout_context")
     private String logoutContext;
 
+    @ApiModelProperty(value = "设备唯一标识ID")
+    @TableField("device_id")
+    private String deviceId;
+
 }

+ 8 - 8
alien-entity/src/main/java/shop/alien/entity/store/StoreInfoDraft.java

@@ -1,20 +1,17 @@
 package shop.alien.entity.store;
 
-import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.*;
 import com.baomidou.mybatisplus.extension.activerecord.Model;
-import java.util.Date;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.FieldFill;
-import com.baomidou.mybatisplus.annotation.TableLogic;
-import com.baomidou.mybatisplus.annotation.TableField;
-import java.io.Serializable;
-import java.util.List;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
 
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+
 /**
  * <p>
  * 门店草稿
@@ -212,6 +209,9 @@ public class StoreInfoDraft extends Model<StoreInfoDraft> {
     @TableField("store_tickets")
     private Integer storeTickets;
 
+    @ApiModelProperty(value = "企业名称")
+    @TableField("company_name")
+    private String companyName;
 
     @Override
     protected Serializable pkVal() {

+ 36 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/CommonPushTaskStatsDto.java

@@ -0,0 +1,36 @@
+package shop.alien.entity.store.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 推送任务统计聚合原始数据
+ */
+@Data
+public class CommonPushTaskStatsDto {
+
+    private Long id;
+
+    private String taskNo;
+
+    private String title;
+
+    private Integer pushType;
+
+    private String status;
+
+    private String channels;
+
+    private Integer estimatedCount;
+
+    private Date createdTime;
+
+    private Long sentCount;
+
+    private Long deliveredCount;
+
+    private Long clickCount;
+
+    private Long showCount;
+}

+ 32 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushFunnelStepVo.java

@@ -0,0 +1,32 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 推送漏斗单步数据
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushFunnelStepVo", description = "推送漏斗单步数据")
+public class CommonPushFunnelStepVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("步骤编码:actual/arrived/show/click")
+    private String stepCode;
+
+    @ApiModelProperty("步骤名称")
+    private String stepName;
+
+    @ApiModelProperty("数量")
+    private Long count;
+
+    @ApiModelProperty("相对上一步转化率(%)")
+    private BigDecimal conversionRate;
+}

+ 26 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushFunnelVo.java

@@ -0,0 +1,26 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 推送漏斗数据
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushFunnelVo", description = "推送漏斗数据")
+public class CommonPushFunnelVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("手机设备类型筛选条件,为空表示全部")
+    private String phoneType;
+
+    @ApiModelProperty("漏斗各步骤")
+    private List<CommonPushFunnelStepVo> steps;
+}

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushReportTopItemVo.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 报表中心 Top 推送项
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushReportTopItemVo", description = "报表中心Top推送项")
+public class CommonPushReportTopItemVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("排名")
+    private Integer rank;
+
+    @ApiModelProperty("推送任务ID")
+    private Long pushTaskId;
+
+    @ApiModelProperty("推送标题")
+    private String title;
+
+    @ApiModelProperty("点击率(%)")
+    private BigDecimal clickRate;
+
+    @ApiModelProperty("送达数")
+    private Long deliveredCount;
+
+    @ApiModelProperty("点击数")
+    private Long clickCount;
+}

+ 46 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushReportVo.java

@@ -0,0 +1,46 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 报表中心数据
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushReportVo", description = "报表中心数据")
+public class CommonPushReportVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("报表类型:1-日报 2-周报 3-月报")
+    private Integer reportType;
+
+    @ApiModelProperty("报表日期(按当前日期及报表类型自动计算)")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date reportDate;
+
+    @ApiModelProperty("报表标题,如:推送日报 — 2026-06-02 (周一)")
+    private String reportTitle;
+
+    @ApiModelProperty("统计开始时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    @ApiModelProperty("统计结束时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date endTime;
+
+    @ApiModelProperty("核心指标概览")
+    private CommonPushStatisticsSummaryVo summary;
+
+    @ApiModelProperty("Top5推送(按点击率排序)")
+    private List<CommonPushReportTopItemVo> topList;
+}

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushStatisticsSummaryVo.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 推送统计概览指标
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushStatisticsSummaryVo", description = "推送统计概览指标")
+public class CommonPushStatisticsSummaryVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("推送量(status=2或3)")
+    private Long pushVolume;
+
+    @ApiModelProperty("送达数(status=3)")
+    private Long deliveredCount;
+
+    @ApiModelProperty("点击数(user_add=1)")
+    private Long clickCount;
+
+    @ApiModelProperty("送达率(送达数/推送量,%)")
+    private BigDecimal deliveryRate;
+
+    @ApiModelProperty("点击率(点击数/送达数,%)")
+    private BigDecimal clickRate;
+
+    @ApiModelProperty("转化率(点击数/推送量,%)")
+    private BigDecimal conversionRate;
+}

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

@@ -0,0 +1,65 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 推送任务统计列表项
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushTaskStatisticsItemVo", description = "推送任务统计列表项")
+public class CommonPushTaskStatisticsItemVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("任务ID")
+    private Long id;
+
+    @ApiModelProperty("任务编号")
+    private String taskNo;
+
+    @ApiModelProperty("推送标题")
+    private String title;
+
+    @ApiModelProperty("推送类型:1-交易类 2-系统类 3-运营类 4-社交类")
+    private Integer pushType;
+
+    @ApiModelProperty("任务状态")
+    private String status;
+
+    @ApiModelProperty("所选渠道")
+    private String channels;
+
+    @ApiModelProperty("目标用户数")
+    private Integer estimatedCount;
+
+    @ApiModelProperty("实际发送数(common_push_task_num.real_send 汇总)")
+    private Long sentCount;
+
+    @ApiModelProperty("送达数(common_push_task_num.real_delivered 汇总)")
+    private Long deliveredCount;
+
+    @ApiModelProperty("点击数(common_push_task_num.click_sum 汇总)")
+    private Long clickCount;
+
+    @ApiModelProperty("推送率(实际发送数/目标用户数,%)")
+    private BigDecimal pushRate;
+
+    @ApiModelProperty("送达率(送达数/实际发送数,%)")
+    private BigDecimal deliveryRate;
+
+    @ApiModelProperty("点击率(点击数/送达数,%)")
+    private BigDecimal clickRate;
+
+    @ApiModelProperty("创建时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+}

+ 26 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CommonPushTaskStatisticsPageVo.java

@@ -0,0 +1,26 @@
+package shop.alien.entity.store.vo;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 推送任务统计分页列表
+ */
+@Data
+@JsonInclude
+@ApiModel(value = "CommonPushTaskStatisticsPageVo", description = "推送任务统计分页列表")
+public class CommonPushTaskStatisticsPageVo implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("概览指标")
+    private CommonPushStatisticsSummaryVo summary;
+
+    @ApiModelProperty("分页列表")
+    private IPage<CommonPushTaskStatisticsItemVo> page;
+}

+ 7 - 0
alien-entity/src/main/java/shop/alien/mapper/CommonPushChannelConfigMapper.java

@@ -0,0 +1,7 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.CommonPushChannelConfig;
+
+public interface CommonPushChannelConfigMapper extends BaseMapper<CommonPushChannelConfig> {
+}

+ 7 - 0
alien-entity/src/main/java/shop/alien/mapper/CommonPushReviewMapper.java

@@ -0,0 +1,7 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.CommonPushReview;
+
+public interface CommonPushReviewMapper extends BaseMapper<CommonPushReview> {
+}

+ 115 - 0
alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskMapper.java

@@ -0,0 +1,115 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.dto.CommonPushTaskStatsDto;
+
+import java.util.Date;
+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 " +
+            NUM_SUM_REAL_SEND + " AS sentCount, " +
+            NUM_SUM_REAL_DELIVERED + " AS deliveredCount, " +
+            NUM_SUM_CLICK + " AS clickCount " +
+            "FROM common_push_task t " +
+            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>" +
+            "<if test='status != null and status != \"\"'>AND t.status = #{status} </if>" +
+            "<if test='pushType != null'>AND t.push_type = #{pushType} </if>" +
+            "<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 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,
+                                                   @Param("status") String status,
+                                                   @Param("pushType") Integer pushType,
+                                                   @Param("channel") String channel,
+                                                   @Param("startTime") Date startTime,
+                                                   @Param("endTime") Date endTime);
+
+    @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, " +
+            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 " +
+            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>" +
+            "<if test='status != null and status != \"\"'>AND t.status = #{status} </if>" +
+            "<if test='pushType != null'>AND t.push_type = #{pushType} </if>" +
+            "<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 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>")
+    IPage<CommonPushTaskStatsDto> selectStatisticsPage(Page<CommonPushTaskStatsDto> page,
+                                                       @Param("taskNo") String taskNo,
+                                                       @Param("keyword") String keyword,
+                                                       @Param("status") String status,
+                                                       @Param("pushType") Integer pushType,
+                                                       @Param("channel") String channel,
+                                                       @Param("startTime") Date startTime,
+                                                       @Param("endTime") Date endTime);
+
+    @Select("<script>" +
+            "SELECT " +
+            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,
+                                           @Param("phoneType") String phoneType);
+
+    @Select("<script>" +
+            "SELECT t.id, t.title, " +
+            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_num n ON n.push_task_id = t.id AND n.delete_flag = 0 " +
+            "WHERE t.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>" +
+            "GROUP BY t.id, t.title " +
+            "HAVING deliveredCount &gt; 0 " +
+            "ORDER BY (clickCount / deliveredCount) DESC " +
+            "LIMIT #{limit}" +
+            "</script>")
+    List<CommonPushTaskStatsDto> selectTopByClickRate(@Param("startTime") Date startTime,
+                                                      @Param("endTime") Date endTime,
+                                                      @Param("limit") Integer limit);
+}

+ 7 - 0
alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskNumMapper.java

@@ -0,0 +1,7 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.CommonPushTaskNum;
+
+public interface CommonPushTaskNumMapper extends BaseMapper<CommonPushTaskNum> {
+}

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

@@ -0,0 +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();
 }

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

@@ -33,7 +33,6 @@ public class LifeUserController {
     @ApiImplicitParams({
             @ApiImplicitParam(name = "phoneNum", value = "手机号", dataType = "String", paramType = "query", required = true),
             @ApiImplicitParam(name = "code", value = "验证码", dataType = "String", paramType = "query", required = true),
-            @ApiImplicitParam(name = "inviteCode", value = "邀请码", dataType = "String", paramType = "query", required = false),
             @ApiImplicitParam(name = "inviteCode", value = "邀请码", dataType = "String", paramType = "query", required = false)
     })
     @GetMapping("/userLogin")
@@ -73,4 +72,17 @@ public class LifeUserController {
         return R.data(userVo);
     }
 
+    @ApiOperation("保存用户设备ID")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "life_user 主键ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "deviceId", value = "设备唯一标识ID", dataType = "String", paramType = "query", required = true)
+    })
+    @PostMapping("/saveDeviceId")
+    public R<String> saveDeviceId(@RequestParam("id") Integer id,
+                                  @RequestParam("deviceId") String deviceId) {
+        log.info("LifeUserController.saveDeviceId?id={}&deviceId={}", id, deviceId);
+        return lifeUserService.saveDeviceId(id, deviceId);
+    }
+
 }

+ 32 - 0
alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java

@@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.result.R;
 import shop.alien.entity.second.LifeUserLog;
 import shop.alien.entity.second.SecondRiskControlRecord;
 import shop.alien.entity.second.SecondUserCredit;
@@ -242,6 +243,37 @@ public class LifeUserService extends ServiceImpl<LifeUserGatewayMapper, LifeUser
     public LifeUser getUserByPhoneDelete(String phoneNum) {
         return lifeUserMapper.selectCancelledDeletedByPhone(phoneNum);
     }
+    public R<String> saveDeviceId(Integer id, String deviceId) {
+        if (id == null || id <= 0) {
+            return R.fail("用户id不能为空");
+        }
+        String normalized = normalizeDeviceId(deviceId);
+        if (StringUtils.isBlank(normalized)) {
+            return R.fail("deviceId不能为空");
+        }
+        LifeUser user = lifeUserMapper.selectById(id);
+        if (user == null) {
+            return R.fail("用户不存在");
+        }
+        if (normalized.equals(user.getDeviceId())) {
+            return R.success("保存成功");
+        }
+        LifeUser update = new LifeUser();
+        update.setId(id);
+        //当原来的字段有值,在后面用逗号拼接
+        update.setDeviceId(user.getDeviceId() == null ? normalized : user.getDeviceId() + "," + normalized);
+//        update.setDeviceId(normalized);
+        return lifeUserMapper.updateById(update) > 0 ? R.success("保存成功") : R.fail("保存失败");
+    }
+
+    private String normalizeDeviceId(String deviceId) {
+        if (StringUtils.isBlank(deviceId)) {
+            return null;
+        }
+        String trimmed = deviceId.trim();
+        return trimmed.length() > 500 ? trimmed.substring(0, 500) : trimmed;
+    }
+
 
     /**
      * 用户登录log存放(加入mac地址)

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

@@ -110,4 +110,20 @@ public interface AlienStoreFeign {
      */
     @PostMapping("/storeCommentAppealSupplement/job/pollCompletedResult")
     R<String> pollStoreCommentAppealSupplementCompletedResult();
+
+    /**
+     * 执行到期的定时推送任务(sendType=2,status=审核通过,scheduledAt &lt;= now)
+     *
+     * @return R.data 为本次成功发送的任务数
+     */
+    @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/CommonPushScheduledJob.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;
+
+/**
+ * 推送任务定时发送(sendType=2)。
+ * <p>
+ * 在 XXL-JOB 管理台配置任务:JobHandler = commonPushScheduledTask,Cron 建议 0 * * * * ?(每分钟)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommonPushScheduledJob {
+
+    private final AlienStoreFeign alienStoreFeign;
+
+    @XxlJob("commonPushScheduledTask")
+    public void commonPushScheduledTask() {
+        log.info("【定时任务】推送定时发送:开始执行");
+        XxlJobHelper.log("【定时任务】推送定时发送:开始执行");
+        try {
+            R<Integer> result = alienStoreFeign.executeScheduledPushTasks();
+            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;
+        }
+    }
+}

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

+ 7 - 0
alien-store/pom.xml

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

+ 21 - 0
alien-store/src/main/java/shop/alien/store/config/CommonPushProperties.java

@@ -0,0 +1,21 @@
+package shop.alien.store.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+/**
+ * 多渠道推送配置(厂商直连,非 UniPush)。
+ */
+@Data
+@Component
+@RefreshScope
+@ConfigurationProperties(prefix = "alien.push")
+public class CommonPushProperties {
+
+    /**
+     * 推送送达回执回调地址,写入厂商推送参数 callBackUrl。
+     */
+    private String callbackUrl = "https://frp-off.com:40279/alienStore/commonPushTaskUser/callback";
+}

+ 99 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushChannelConfigController.java

@@ -0,0 +1,99 @@
+package shop.alien.store.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushChannelConfig;
+import shop.alien.store.service.CommonPushChannelConfigService;
+
+@Api(tags = {"推送渠道配置管理"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/commonPushChannelConfig")
+@RequiredArgsConstructor
+public class CommonPushChannelConfigController {
+
+    private final CommonPushChannelConfigService commonPushChannelConfigService;
+
+    @ApiOperation("新增推送渠道配置")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/add")
+    public R<String> add(@RequestBody CommonPushChannelConfig config) {
+        return commonPushChannelConfigService.add(config);
+    }
+
+    @ApiOperation("根据主键删除(逻辑删除)")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/deleteById")
+    public R<String> deleteById(@RequestParam Long id) {
+        return commonPushChannelConfigService.deleteById(id);
+    }
+
+    @ApiOperation("更新推送渠道配置")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/update")
+    public R<String> update(@RequestBody CommonPushChannelConfig config) {
+        return commonPushChannelConfigService.update(config);
+    }
+
+    @ApiOperation("根据主键查询")
+    @ApiOperationSupport(order = 4)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/getById")
+    public R<CommonPushChannelConfig> getById(@RequestParam Long id) {
+        return commonPushChannelConfigService.getInfoById(id);
+    }
+
+    @ApiOperation("分页列表")
+    @ApiOperationSupport(order = 5)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "channelCode", value = "渠道编码", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "platform", value = "平台", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "enable", value = "是否启用", dataType = "Integer", paramType = "query")
+    })
+    @GetMapping("/list")
+    public R<IPage<CommonPushChannelConfig>> list(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) String channelCode,
+            @RequestParam(required = false) String platform,
+            @RequestParam(required = false) Integer enable) {
+        return commonPushChannelConfigService.list(pageNum, pageSize, channelCode, platform, enable);
+    }
+
+    @ApiOperation("启用/停用渠道")
+    @ApiOperationSupport(order = 6)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true),
+            @ApiImplicitParam(name = "enable", value = "0-停用 1-启用", dataType = "Integer", paramType = "query", required = true)
+    })
+    @PostMapping("/toggleEnable")
+    public R<String> toggleEnable(@RequestParam Long id, @RequestParam Integer enable) {
+        return commonPushChannelConfigService.toggleEnable(id, enable);
+    }
+
+    @ApiOperation("连通性测试")
+    @ApiOperationSupport(order = 7)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @PostMapping("/testConnect")
+    public R<String> testConnect(@RequestParam Long id) {
+        return commonPushChannelConfigService.testConnect(id);
+    }
+}

+ 76 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushReviewController.java

@@ -0,0 +1,76 @@
+package shop.alien.store.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushReview;
+import shop.alien.store.service.CommonPushReviewService;
+
+@Api(tags = {"推送审核管理"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/commonPushReview")
+@RequiredArgsConstructor
+public class CommonPushReviewController {
+
+    private final CommonPushReviewService commonPushReviewService;
+
+    @ApiOperation("新增推送审核记录")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/add")
+    public R<String> add(@RequestBody CommonPushReview review) {
+        return commonPushReviewService.add(review);
+    }
+
+    @ApiOperation("根据主键删除(逻辑删除)")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/deleteById")
+    public R<String> deleteById(@RequestParam Long id) {
+        return commonPushReviewService.deleteById(id);
+    }
+
+    @ApiOperation("更新推送审核记录")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/update")
+    public R<String> update(@RequestBody CommonPushReview review) {
+        return commonPushReviewService.update(review);
+    }
+
+    @ApiOperation("根据主键查询")
+    @ApiOperationSupport(order = 4)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/getById")
+    public R<CommonPushReview> getById(@RequestParam Long id) {
+        return commonPushReviewService.getInfoById(id);
+    }
+
+    @ApiOperation("分页列表")
+    @ApiOperationSupport(order = 5)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pushTaskId", value = "推送任务ID", dataType = "Long", paramType = "query"),
+            @ApiImplicitParam(name = "reviewStatus", value = "审核状态", dataType = "Integer", paramType = "query")
+    })
+    @GetMapping("/list")
+    public R<IPage<CommonPushReview>> list(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) Long pushTaskId,
+            @RequestParam(required = false) Integer reviewStatus) {
+        return commonPushReviewService.list(pageNum, pageSize, pushTaskId, reviewStatus);
+    }
+}

+ 158 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java

@@ -0,0 +1,158 @@
+package shop.alien.store.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.vo.CommonPushFunnelVo;
+import shop.alien.entity.store.vo.CommonPushReportVo;
+import shop.alien.entity.store.vo.CommonPushTaskStatisticsPageVo;
+import shop.alien.store.dto.CommonPushSendResultDto;
+import shop.alien.store.dto.CommonPushTestSendDto;
+import shop.alien.store.service.CommonPushTaskService;
+
+import java.util.Date;
+
+@Api(tags = {"推送任务管理"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/commonPushTask")
+@RequiredArgsConstructor
+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")
+    public R<String> add(@RequestBody CommonPushTask task) {
+        return commonPushTaskService.add(task);
+    }
+
+    @ApiOperation("保存草稿")
+    @ApiOperationSupport(order = 2)
+    @PostMapping("/saveDraft")
+    public R<Long> saveDraft(@RequestBody CommonPushTask task) {
+        return commonPushTaskService.saveDraft(task);
+    }
+
+    @ApiOperation("根据主键删除(逻辑删除)")
+    @ApiOperationSupport(order = 3)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/deleteById")
+    public R<String> deleteById(@RequestParam Long id) {
+        return commonPushTaskService.deleteById(id);
+    }
+
+    @ApiOperation("更新推送任务")
+    @ApiOperationSupport(order = 4)
+    @PostMapping("/update")
+    public R<String> update(@RequestBody CommonPushTask task) {
+        return commonPushTaskService.update(task);
+    }
+
+    @ApiOperation("根据主键查询")
+    @ApiOperationSupport(order = 5)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/getById")
+    public R<CommonPushTask> getById(@RequestParam Long id) {
+        return commonPushTaskService.getInfoById(id);
+    }
+
+    @ApiOperation("分页列表")
+    @ApiOperationSupport(order = 6)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "taskNo", value = "任务编号", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "title", value = "标题(模糊)", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "status", value = "任务状态:0-草稿 1-待审核 2-已发送 3-已送达 4-失败 5-审核通过 6-驳回", dataType = "Integer", paramType = "query"),
+            @ApiImplicitParam(name = "pushType", value = "推送类型", dataType = "Integer", paramType = "query")
+    })
+    @GetMapping("/list")
+    public R<IPage<CommonPushTask>> list(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) String taskNo,
+            @RequestParam(required = false) String title,
+            @RequestParam(required = false) String status,
+            @RequestParam(required = false) Integer pushType) {
+        return commonPushTaskService.list(pageNum, pageSize, taskNo, title, status, pushType);
+    }
+
+    @ApiOperation("推送统计列表(含概览指标)")
+    @ApiOperationSupport(order = 6)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "taskNo", value = "任务编号", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "keyword", value = "搜索标题或编号", dataType = "String", paramType = "query"),
+            @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 = "开始时间(传日期包含当天 00:00:00 起)", dataType = "Date", paramType = "query"),
+            @ApiImplicitParam(name = "endTime", value = "结束时间(传日期包含当天 23:59:59 止)", dataType = "Date", paramType = "query")
+    })
+    @GetMapping("/statisticsList")
+    public R<CommonPushTaskStatisticsPageVo> statisticsList(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) String taskNo,
+            @RequestParam(required = false) String keyword,
+            @RequestParam(required = false) String status,
+            @RequestParam(required = false) Integer pushType,
+            @RequestParam(required = false) String channel,
+            @RequestParam(required = false) Date startTime,
+            @RequestParam(required = false) Date endTime) {
+        return commonPushTaskService.statisticsList(pageNum, pageSize, taskNo, keyword, status, pushType, channel, startTime, endTime);
+    }
+
+    @ApiOperation("推送漏斗统计")
+    @ApiOperationSupport(order = 7)
+    @ApiImplicitParams({
+            @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")
+    })
+    @GetMapping("/funnel")
+    public R<CommonPushFunnelVo> funnel(
+            @RequestParam(required = false) String phoneType,
+            @RequestParam(required = false) Date startTime,
+            @RequestParam(required = false) Date endTime) {
+        return commonPushTaskService.funnel(phoneType, startTime, endTime);
+    }
+
+    @ApiOperation("发送测试推送")
+    @ApiOperationSupport(order = 9)
+    @PostMapping("/sendTest")
+    public R<CommonPushSendResultDto> sendTest(@RequestBody CommonPushTestSendDto dto) {
+        return commonPushTaskService.sendTest(dto);
+    }
+
+    @ApiOperation(value = "报表中心(日报/周报/月报)", notes = "概览与 TOP 榜单均基于 common_push_task_num 汇总")
+    @ApiOperationSupport(order = 8)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "reportType", value = "报表类型:1-日报 2-周报 3-月报", dataType = "Integer", paramType = "query")
+    })
+    @GetMapping("/report")
+    public R<CommonPushReportVo> report(@RequestParam(required = false) Integer reportType) {
+        return commonPushTaskService.report(reportType);
+    }
+}

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

@@ -0,0 +1,42 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import shop.alien.entity.result.R;
+import shop.alien.store.service.CommonPushTaskService;
+
+/**
+ * 推送任务定时任务回调(供 XXL-JOB 通过 Feign 调用)
+ */
+@Slf4j
+@Api(tags = {"推送定时任务"})
+@RestController
+@RequestMapping("/commonPushTask/job")
+@RequiredArgsConstructor
+public class CommonPushTaskJobController {
+
+    private final CommonPushTaskService commonPushTaskService;
+
+    @ApiOperation("执行到期的定时推送任务")
+    @PostMapping("/executeScheduled")
+    public R<Integer> executeScheduled() {
+        log.info("commonPushTask job: executeScheduled 开始");
+        int count = commonPushTaskService.executeScheduledPushTasks();
+        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);
+    }
+}

+ 77 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskNumController.java

@@ -0,0 +1,77 @@
+package shop.alien.store.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTaskNum;
+import shop.alien.store.service.CommonPushTaskNumService;
+
+@Api(tags = {"推送任务数量统计管理"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/commonPushTaskNum")
+@RequiredArgsConstructor
+public class CommonPushTaskNumController {
+
+    private final CommonPushTaskNumService commonPushTaskNumService;
+
+    @ApiOperation("新增推送任务数量统计")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/add")
+    public R<String> add(@RequestBody CommonPushTaskNum taskNum) {
+        return commonPushTaskNumService.add(taskNum);
+    }
+
+    @ApiOperation("根据主键删除(逻辑删除)")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/deleteById")
+    public R<String> deleteById(@RequestParam Long id) {
+        return commonPushTaskNumService.deleteById(id);
+    }
+
+    @ApiOperation("更新推送任务数量统计")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/update")
+    public R<String> update(@RequestBody CommonPushTaskNum taskNum) {
+        return commonPushTaskNumService.update(taskNum);
+    }
+
+    @ApiOperation("根据主键查询")
+    @ApiOperationSupport(order = 4)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/getById")
+    public R<CommonPushTaskNum> getById(@RequestParam Long id) {
+        return commonPushTaskNumService.getInfoById(id);
+    }
+
+    @ApiOperation("分页列表")
+    @ApiOperationSupport(order = 5)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pushTaskId", value = "推送任务ID", dataType = "Long", paramType = "query"),
+            @ApiImplicitParam(name = "phoneType", value = "机型:1-OPPO 2-VIVO 3-华为 4-苹果 5-荣耀 6-小米 7-三星", dataType = "String", paramType = "query")
+
+    })
+    @GetMapping("/list")
+    public R<IPage<CommonPushTaskNum>> list(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) Long pushTaskId,
+            @RequestParam(required = false) String phoneType) {
+        return commonPushTaskNumService.list(pageNum, pageSize, pushTaskId, phoneType);
+    }
+}

+ 112 - 0
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskUserController.java

@@ -0,0 +1,112 @@
+package shop.alien.store.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTaskUser;
+import shop.alien.store.dto.CommonPushTaskUserCallbackDto;
+import shop.alien.store.service.CommonPushTaskUserService;
+
+@Api(tags = {"推送任务用户管理"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/commonPushTaskUser")
+@RequiredArgsConstructor
+public class CommonPushTaskUserController {
+
+    private final CommonPushTaskUserService commonPushTaskUserService;
+
+    @ApiOperation("新增推送任务用户关联")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/add")
+    public R<String> add(@RequestBody CommonPushTaskUser taskUser) {
+        return commonPushTaskUserService.add(taskUser);
+    }
+
+    @ApiOperation("根据主键删除(逻辑删除)")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/deleteById")
+    public R<String> deleteById(@RequestParam Long id) {
+        return commonPushTaskUserService.deleteById(id);
+    }
+
+    @ApiOperation("更新推送任务用户关联")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/update")
+    public R<String> update(@RequestBody CommonPushTaskUser taskUser) {
+        return commonPushTaskUserService.update(taskUser);
+    }
+
+    @ApiOperation("根据主键查询")
+    @ApiOperationSupport(order = 4)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/getById")
+    public R<CommonPushTaskUser> getById(@RequestParam Long id) {
+        return commonPushTaskUserService.getInfoById(id);
+    }
+
+    @ApiOperation("分页列表")
+    @ApiOperationSupport(order = 5)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pushTaskId", value = "推送任务ID", dataType = "Long", paramType = "query"),
+            @ApiImplicitParam(name = "userId", value = "用户ID", dataType = "Long", paramType = "query"),
+            @ApiImplicitParam(name = "deviceId", value = "设备ID", dataType = "String", paramType = "query")
+    })
+    @GetMapping("/list")
+    public R<IPage<CommonPushTaskUser>> list(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) Long pushTaskId,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) String deviceId) {
+        return commonPushTaskUserService.list(pageNum, pageSize, pushTaskId, userId, deviceId);
+    }
+
+    @ApiOperation("推送状态回调(各渠道统一入口,支持 JSON 与 OPPO 表单回执)")
+    @ApiOperationSupport(order = 6)
+    @PostMapping("/callback")
+    public R<String> callback(@RequestBody(required = false) CommonPushTaskUserCallbackDto callbackDto,
+                              @RequestParam(required = false) String callBackParameter,
+                              @RequestParam(required = false) String param,
+                              @RequestParam(required = false) String status,
+                              @RequestParam(required = false) String registrationIds,
+                              @RequestParam(required = false) String registration_id,
+                              @RequestParam(required = false) String eventType) {
+        if (callbackDto != null && StringUtils.isNotBlank(callbackDto.getParam())
+                && StringUtils.isBlank(callBackParameter) && StringUtils.isBlank(param)) {
+            param = callbackDto.getParam();
+        }
+        String vendorParam = StringUtils.defaultIfBlank(callBackParameter, param);
+        if (StringUtils.isBlank(vendorParam) && callbackDto != null) {
+            vendorParam = callbackDto.getParam();
+        }
+        String regIds = StringUtils.defaultIfBlank(registrationIds, registration_id);
+        if (StringUtils.isBlank(regIds) && callbackDto != null) {
+            regIds = callbackDto.getRegistrationIds();
+        }
+        if (StringUtils.isNotBlank(vendorParam)) {
+            return commonPushTaskUserService.callbackFromVendor(vendorParam, status, regIds, eventType);
+        }
+        if (callbackDto != null && (callbackDto.getId() != null || callbackDto.getPushTaskId() != null
+                || StringUtils.isNotBlank(callbackDto.getTaskNo()) || StringUtils.isNotBlank(callbackDto.getDeviceId()))) {
+            return commonPushTaskUserService.callback(callbackDto);
+        }
+        return commonPushTaskUserService.callback(callbackDto);
+    }
+}

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

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

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

@@ -0,0 +1,30 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 推送发送结果
+ */
+@Data
+public class CommonPushSendResultDto {
+
+    private boolean success;
+
+    private String message;
+
+    private int sentCount;
+
+    private int failedCount;
+
+    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<>();
+}

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

@@ -0,0 +1,21 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+
+/**
+ * 推送目标设备
+ */
+@Data
+public class CommonPushTargetDto {
+
+    private Long userId;
+
+    /** 厂商推送 token / uni-push cid */
+    private String deviceId;
+
+    /** ios / android */
+    private String platform;
+
+    /** 是否厂商广播推送(如 OPPO target_type=1 全量) */
+    private Boolean broadcast;
+}

+ 52 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushTaskUserCallbackDto.java

@@ -0,0 +1,52 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 各渠道推送状态回调入参(统一格式)
+ */
+@Data
+@ApiModel(value = "CommonPushTaskUserCallbackDto", description = "推送任务用户状态回调")
+public class CommonPushTaskUserCallbackDto {
+
+    @ApiModelProperty("关联记录主键,与 pushTaskId+deviceId 二选一")
+    private Long id;
+
+    @ApiModelProperty("任务编号,与 pushTaskId 二选一(OPPO callBackParameter 回传)")
+    private String taskNo;
+
+    @ApiModelProperty("推送任务ID")
+    private Long pushTaskId;
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty("设备唯一标识ID")
+    private String deviceId;
+
+    @ApiModelProperty("渠道编码:apns/huawei/xiaomi/oppo/vivo/honor")
+    private String channelCode;
+
+    @ApiModelProperty("事件类型:sent-已发送 delivered-已送达 show-已展示 click-已点击")
+    private String eventType;
+
+    @ApiModelProperty("OPPO 回执自定义参数(同 callBackParameter)")
+    private String param;
+
+    @ApiModelProperty("OPPO 回执 registrationIds,逗号分隔")
+    private String registrationIds;
+
+    @ApiModelProperty("渠道原始状态码,由各渠道回调解析")
+    private String channelStatus;
+
+    @ApiModelProperty("发送状态:0-已发送 1-已送达")
+    private Integer status;
+
+    @ApiModelProperty("展示状态:0-未展示 1-已展示")
+    private Integer showInfo;
+
+    @ApiModelProperty("点击状态:0-未点击 1-已点击")
+    private Integer userAdd;
+}

+ 31 - 0
alien-store/src/main/java/shop/alien/store/dto/CommonPushTestSendDto.java

@@ -0,0 +1,31 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 发送测试推送入参
+ */
+@Data
+@ApiModel(value = "CommonPushTestSendDto", description = "发送测试推送")
+public class CommonPushTestSendDto {
+
+    @ApiModelProperty(value = "目标设备 device_id(厂商 RegistrationID)", required = true)
+    private String deviceId;
+
+    @ApiModelProperty(value = "推送标题,默认「测试推送」")
+    private String title;
+
+    @ApiModelProperty(value = "推送正文,默认「这是一条测试推送消息」")
+    private String content;
+
+    @ApiModelProperty(value = "指定厂商渠道:oppo / vivo / xiaomi / huawei / honor / samsung / apns,不传则按平台自动选择")
+    private String vendorChannel;
+
+    @ApiModelProperty(value = "点击跳转链接")
+    private String jumpUrl;
+
+    @ApiModelProperty(value = "平台:android / ios,默认 android")
+    private String platform;
+}

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

+ 23 - 0
alien-store/src/main/java/shop/alien/store/service/CommonPushChannelConfigService.java

@@ -0,0 +1,23 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushChannelConfig;
+
+public interface CommonPushChannelConfigService extends IService<CommonPushChannelConfig> {
+
+    R<String> add(CommonPushChannelConfig config);
+
+    R<String> deleteById(Long id);
+
+    R<String> update(CommonPushChannelConfig config);
+
+    R<CommonPushChannelConfig> getInfoById(Long id);
+
+    R<IPage<CommonPushChannelConfig>> list(Integer pageNum, Integer pageSize, String channelCode, String platform, Integer enable);
+
+    R<String> toggleEnable(Long id, Integer enable);
+
+    R<String> testConnect(Long id);
+}

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

@@ -0,0 +1,19 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushReview;
+
+public interface CommonPushReviewService extends IService<CommonPushReview> {
+
+    R<String> add(CommonPushReview review);
+
+    R<String> deleteById(Long id);
+
+    R<String> update(CommonPushReview review);
+
+    R<CommonPushReview> getInfoById(Long id);
+
+    R<IPage<CommonPushReview>> list(Integer pageNum, Integer pageSize, Long pushTaskId, Integer reviewStatus);
+}

+ 37 - 0
alien-store/src/main/java/shop/alien/store/service/CommonPushSendService.java

@@ -0,0 +1,37 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.CommonPushChannelConfig;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.store.dto.CommonPushSendResultDto;
+import shop.alien.store.dto.CommonPushTargetDto;
+
+import java.util.List;
+
+public interface CommonPushSendService {
+
+    /**
+     * 查询当前可参与下发的渠道配置(已启用、凭证完整、未超配额)。
+     */
+    List<CommonPushChannelConfig> listSendableChannels();
+
+    /**
+     * 根据任务目标配置解析推送设备列表。
+     */
+    List<CommonPushTargetDto> resolveTargets(CommonPushTask task);
+
+    /**
+     * 在任务入库前执行多渠道推送。
+     */
+    CommonPushSendResultDto send(CommonPushTask task);
+
+    /**
+     * 任务保存后写入 common_push_task_user 发送记录。
+     */
+    void saveTaskUserRecords(Long pushTaskId, CommonPushSendResultDto sendResult);
+
+    /**
+     * 测试推送:仅向指定 deviceId 发送,不入库、不走审核。
+     */
+    CommonPushSendResultDto sendTest(String deviceId, String title, String content,
+                                     String vendorChannel, String jumpUrl, String platform);
+}

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

@@ -0,0 +1,19 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTaskNum;
+
+public interface CommonPushTaskNumService extends IService<CommonPushTaskNum> {
+
+    R<String> add(CommonPushTaskNum taskNum);
+
+    R<String> deleteById(Long id);
+
+    R<String> update(CommonPushTaskNum taskNum);
+
+    R<CommonPushTaskNum> getInfoById(Long id);
+
+    R<IPage<CommonPushTaskNum>> list(Integer pageNum, Integer pageSize, Long pushTaskId, String phoneType);
+}

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

@@ -0,0 +1,57 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.vo.CommonPushFunnelVo;
+import shop.alien.entity.store.vo.CommonPushReportVo;
+import shop.alien.entity.store.vo.CommonPushTaskStatisticsPageVo;
+import shop.alien.store.dto.CommonPushSendResultDto;
+import shop.alien.store.dto.CommonPushTestSendDto;
+
+import java.util.Date;
+
+public interface CommonPushTaskService extends IService<CommonPushTask> {
+
+    R<String> add(CommonPushTask task);
+
+    /**
+     * 保存草稿(status=0),不进行 AI 审核与推送发送。
+     */
+    R<Long> saveDraft(CommonPushTask task);
+
+    R<String> deleteById(Long id);
+
+    R<String> update(CommonPushTask task);
+
+    R<CommonPushTask> getInfoById(Long id);
+
+    R<IPage<CommonPushTask>> list(Integer pageNum, Integer pageSize, String taskNo, String title, String status, Integer pushType);
+
+    R<CommonPushTaskStatisticsPageVo> statisticsList(Integer pageNum, Integer pageSize, String taskNo, String keyword,
+                                                     String status, Integer pushType, String channel,
+                                                     Date startTime, Date endTime);
+
+    R<CommonPushFunnelVo> funnel(String phoneType, Date startTime, Date endTime);
+
+//    R<CommonPushReportVo> report(Integer reportType, Date reportDate);
+
+    R<CommonPushSendResultDto> sendTest(CommonPushTestSendDto dto);
+
+    /**
+     * 执行到期的定时推送任务(供 XXL-JOB 调用)。
+     *
+     * @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);
+}

+ 27 - 0
alien-store/src/main/java/shop/alien/store/service/CommonPushTaskUserService.java

@@ -0,0 +1,27 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTaskUser;
+import shop.alien.store.dto.CommonPushTaskUserCallbackDto;
+
+public interface CommonPushTaskUserService extends IService<CommonPushTaskUser> {
+
+    R<String> add(CommonPushTaskUser taskUser);
+
+    R<String> deleteById(Long id);
+
+    R<String> update(CommonPushTaskUser taskUser);
+
+    R<CommonPushTaskUser> getInfoById(Long id);
+
+    R<IPage<CommonPushTaskUser>> list(Integer pageNum, Integer pageSize, Long pushTaskId, Long userId, String deviceId);
+
+    R<String> callback(CommonPushTaskUserCallbackDto callbackDto);
+
+    /**
+     * 解析 OPPO 等厂商回执(callBackParameter / param 字段)并更新状态。
+     */
+    R<String> callbackFromVendor(String callBackParameter, String channelStatus, String registrationIds, String eventType);
+}

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

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

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

@@ -108,7 +108,7 @@ public interface StoreCommentAppealService extends IService<StoreCommentAppeal>
      * 判断用户是否可以评价,并返回最新申诉通过记录及10天冷却倒计时
      *
      * @param userId 用户ID
-     * @return canRate-是否可评价, passedCount-近30天申诉通过次数, latestPassedAppeal-最新通过记录, countdown-10天内剩余冷却(X天X小时
+     * @return canRate-是否可评价, passedCount-近30天申诉通过次数, latestPassedAppeal-最新通过记录, countdown-10天内剩余冷却(X天)
      */
     Map<String, Object> canRate(Integer userId);
 }

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

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

+ 1284 - 0
alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java

@@ -0,0 +1,1284 @@
+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.lang3.StringUtils;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+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.util.*;
+
+/**
+ * 各厂商推送 HTTP 客户端,凭证从配置表 credential_json 读取。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CommonPushVendorHttpClient {
+
+    private static final int TIMEOUT_MS = 8000;
+    /** OPPO call_back_parameter 最大 100 字符 */
+    private static final int OPPO_CALLBACK_PARAM_MAX_LEN = 100;
+
+    private static final int HUAWEI_BATCH_TOKEN_LIMIT = 1000;
+    /** 华为 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;
+
+    private static final RestTemplate restTemplate = new RestTemplate();
+
+    /**
+     * OPPO 全量广播推送(target_type=1),用于 targetType=1 全量场景。
+     */
+    public CommonPushVendorSendResult sendOppoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        try {
+            String authToken = obtainOppoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return CommonPushVendorSendResult.fail();
+            }
+            String messageId = saveOppoMessageContent(authToken, task, null);
+            if (StringUtils.isBlank(messageId)) {
+                return CommonPushVendorSendResult.fail();
+            }
+            String taskId = broadcastOppoMessage(authToken, messageId);
+            if (StringUtils.isBlank(taskId)) {
+                return CommonPushVendorSendResult.fail();
+            }
+            return CommonPushVendorSendResult.ok(taskId, messageId);
+        } catch (Exception e) {
+            log.error("OPPO 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /**
+     * 小米全量广播推送,用于 targetType=1 全量场景。
+     */
+    public CommonPushVendorSendResult sendXiaomiBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isBlank(appSecret)) {
+            return CommonPushVendorSendResult.fail();
+        }
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            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 parseXiaomiSendResult(resp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("小米广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /**
+     * vivo 全量广播推送,用于 targetType=1 全量场景。
+     */
+    public CommonPushVendorSendResult sendVivoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        try {
+            String authToken = obtainVivoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                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 parseVivoSendResult(sendResp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("vivo 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /**
+     * 华为全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public CommonPushVendorSendResult sendHuaweiBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                       List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("华为全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            String requestId = null;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                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 ? CommonPushVendorSendResult.ok(requestId) : CommonPushVendorSendResult.fail();
+        } catch (Exception e) {
+            log.error("华为全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /**
+     * 荣耀全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public CommonPushVendorSendResult sendHonorBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                      List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("荣耀全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            String requestId = null;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                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 ? CommonPushVendorSendResult.ok(requestId) : CommonPushVendorSendResult.fail();
+        } catch (Exception e) {
+            log.error("荣耀全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /**
+     * APNs 全量推送:按 iOS deviceToken 逐条下发(Apple 无 /all 接口)。
+     */
+    public CommonPushVendorSendResult sendApnsBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                     List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null || !apnsPushClient.validateCredential(credential)) {
+            return CommonPushVendorSendResult.fail();
+        }
+        List<CommonPushTargetDto> apnsTargets = ApnsPushClient.filterApnsTargets(targets, targets);
+        if (apnsTargets.isEmpty()) {
+            log.warn("APNs 全量推送无 iOS 设备 token, taskNo={}", task.getTaskNo());
+            return CommonPushVendorSendResult.fail();
+        }
+        boolean ok = apnsPushClient.sendBroadcast(credential, task, apnsTargets);
+        return ok ? CommonPushVendorSendResult.ok(task.getTaskNo()) : CommonPushVendorSendResult.fail();
+    }
+
+    public CommonPushVendorSendResult send(CommonPushChannelConfig config, CommonPushTask task, CommonPushTargetDto target) {
+        if (config == null || task == null || target == null || StringUtils.isBlank(target.getDeviceId())) {
+            return CommonPushVendorSendResult.fail();
+        }
+        String deviceId = target.getDeviceId();
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return CommonPushVendorSendResult.fail();
+        }
+        String channelCode = StringUtils.lowerCase(StringUtils.trim(config.getChannelCode()));
+        try {
+            switch (channelCode) {
+                case "vivo":
+                    return sendVivo(credential, task, target);
+                case "xiaomi":
+                    return sendXiaomi(credential, task, target);
+                case "oppo":
+                    return sendOppo(credential, task, target);
+                case "samsung":
+                    return sendSamsung(credential, task, target) ? CommonPushVendorSendResult.ok(task.getTaskNo())
+                            : CommonPushVendorSendResult.fail();
+                case "huawei":
+                    return sendHuawei(credential, task, deviceId);
+                case "honor":
+                    return sendHonor(credential, task, deviceId);
+                case "apns":
+                    return sendApns(credential, task, deviceId);
+                default:
+                    log.warn("不支持的推送渠道: {}", channelCode);
+                    return CommonPushVendorSendResult.fail();
+            }
+        } catch (Exception e) {
+            log.error("渠道推送失败, channelCode={}, deviceId={}, err={}", channelCode, deviceId, e.getMessage(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    private CommonPushVendorSendResult sendVivo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        if (StringUtils.isBlank(deviceId)) {
+            return CommonPushVendorSendResult.fail();
+        }
+        String authToken = obtainVivoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
+            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 parseVivoSendResult(sendResp, task.getTaskNo(), "单推");
+    }
+
+    private String obtainVivoAuthToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appKey = credential.getString("appKey");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appKey, appSecret)) {
+            return null;
+        }
+        long timestamp = System.currentTimeMillis();
+        String sign = md5Hex(appId + appKey + timestamp + appSecret);
+        JSONObject authBody = new JSONObject();
+        authBody.put("appId", appId);
+        authBody.put("appKey", appKey);
+        authBody.put("timestamp", timestamp);
+        authBody.put("sign", sign);
+        String authResp = postJson("https://api-push.vivo.com.cn/message/auth", authBody.toJSONString(), null);
+        JSONObject authJson = JSONObject.parseObject(authResp);
+        if (authJson == null || authJson.getIntValue("result") != 0) {
+            log.warn("vivo 鉴权失败: {}", authResp);
+            return null;
+        }
+        return authJson.getString("authToken");
+    }
+
+    private JSONObject buildVivoNotificationBody(JSONObject credential, CommonPushTask task,
+                                                    CommonPushTargetDto target) {
+        JSONObject sendBody = new JSONObject();
+        sendBody.put("appId", credential.getString("appId"));
+        sendBody.put("notifyType", 1);
+        sendBody.put("title", task.getTitle());
+        sendBody.put("content", task.getContent());
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            sendBody.put("skipType", 2);
+            sendBody.put("skipContent", task.getJumpUrl());
+        } else {
+            sendBody.put("skipType", 1);
+        }
+        sendBody.put("requestId", buildVivoRequestId(task));
+        JSONObject extra = new JSONObject();
+        extra.put("callback", resolveCallbackUrl());
+        extra.put("callback.param", buildVivoCallbackParameter(task, target));
+        sendBody.put("extra", extra);
+        return sendBody;
+    }
+
+    private String buildVivoRequestId(CommonPushTask task) {
+        String base = StringUtils.defaultIfBlank(task.getTaskNo(), "TASK");
+        String requestId = base + "_" + System.currentTimeMillis();
+        return requestId.length() > 64 ? requestId.substring(0, 64) : requestId;
+    }
+
+    private String buildVivoCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        param.put("c", "vivo");
+        return param.toJSONString();
+    }
+
+    private 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) {
+            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 CommonPushVendorSendResult.fail();
+    }
+
+    private CommonPushVendorSendResult sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appSecret, deviceId)) {
+            return CommonPushVendorSendResult.fail();
+        }
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            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 parseXiaomiSendResult(resp, task.getTaskNo(), "单推");
+    }
+
+    private boolean validateXiaomiCredential(JSONObject credential, String taskNo) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isBlank(channelId)) {
+            log.error("小米推送缺少 channelId, taskNo={}。小米运营平台已创建 channel 后,还需将 channel_id 写入本系统 common_push_channel_config.credentialJson.channelId",
+                    taskNo);
+            return false;
+        }
+        if (StringUtils.isBlank(credential.getString("packageName"))) {
+            log.warn("小米推送未配置 packageName, taskNo={}, channelId={},请确认与小米应用信息中的包名一致",
+                    taskNo, channelId);
+        }
+        return true;
+    }
+
+    private String resolveXiaomiChannelId(JSONObject credential) {
+        if (credential == null) {
+            return null;
+        }
+        return StringUtils.defaultIfBlank(
+                StringUtils.trimToNull(credential.getString("channelId")),
+                StringUtils.trimToNull(credential.getString("channel_id")));
+    }
+
+    private 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"))) {
+            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 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,
+                                                                   CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("title", task.getTitle());
+        form.add("description", task.getContent());
+        form.add("payload", buildPayload(task));
+        String packageName = credential.getString("packageName");
+        if (StringUtils.isNotBlank(packageName)) {
+            form.add("restricted_package_name", packageName.trim());
+        } else {
+            log.warn("小米推送未配置 packageName,多包名应用可能返回 22022 或 27001");
+        }
+        appendXiaomiExtraParams(form, credential, task, target);
+        return form;
+    }
+
+    /**
+     * 小米 HTTP API 要求以 extra.xxx 形式传参(非 extra JSON 字符串)。
+     */
+    private void appendXiaomiExtraParams(MultiValueMap<String, String> form, JSONObject credential,
+                                           CommonPushTask task, CommonPushTargetDto target) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isNotBlank(channelId)) {
+            form.add("extra.channel_id", channelId);
+        }
+        form.add("extra.callback", resolveCallbackUrl());
+        form.add("extra.callback.param", buildXiaomiCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("extra.notify_effect", "3");
+            form.add("extra.web_uri", task.getJumpUrl());
+        }
+    }
+
+    private String buildXiaomiCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        param.put("c", "xiaomi");
+        return param.toJSONString();
+    }
+
+    /**
+     * OPPO 厂商直连:使用 RegistrationID(非 UniPush CID),并设置送达回执回调。
+     */
+    private CommonPushVendorSendResult sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String authToken = obtainOppoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
+            return CommonPushVendorSendResult.fail();
+        }
+
+        JSONObject notification = buildOppoNotification(task, target);
+        JSONObject messageRoot = new JSONObject();
+        messageRoot.put("target_type", 2);
+        messageRoot.put("target_value", deviceId);
+        messageRoot.put("notification", notification);
+
+        MultiValueMap<String, String> sendForm = new LinkedMultiValueMap<>();
+        sendForm.add("auth_token", authToken);
+        sendForm.add("message", messageRoot.toJSONString());
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(sendForm, headers);
+        try {
+            ResponseEntity<String> response = restTemplate.postForEntity(
+                    "https://api.push.oppomobile.com/server/v1/message/notification/unicast",
+                    entity,
+                    String.class
+            );
+            String body = response.getBody();
+            log.info("OPPO 单推响应: {}", body);
+            return parseOppoSendResult(body);
+        } catch (Exception e) {
+            log.error("OPPO 单推请求异常: {}", e.getMessage(), e);
+            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) {
+        String appKey = credential.getString("appKey");
+        String masterSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appKey, masterSecret)) {
+            return null;
+        }
+        long timestamp = System.currentTimeMillis();
+        String sign = cn.hutool.crypto.SecureUtil.sha256(appKey + timestamp + masterSecret);
+        MultiValueMap<String, String> authForm = new LinkedMultiValueMap<>();
+        authForm.add("app_key", appKey);
+        authForm.add("timestamp", String.valueOf(timestamp));
+        authForm.add("sign", sign);
+        String authResp = postForm("https://api.push.oppomobile.com/server/v1/auth", authForm, null);
+        JSONObject authJson = JSONObject.parseObject(authResp);
+        if (authJson == null || authJson.getJSONObject("data") == null) {
+            log.warn("OPPO 鉴权失败: {}", authResp);
+            return null;
+        }
+        return authJson.getJSONObject("data").getString("auth_token");
+    }
+
+    private JSONObject buildOppoNotification(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject notification = new JSONObject();
+        notification.put("title", task.getTitle());
+        notification.put("content", task.getContent());
+        notification.put("call_back_url", resolveCallbackUrl());
+        notification.put("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            notification.put("click_action_url", task.getJumpUrl());
+        }
+        return notification;
+    }
+
+    /**
+     * 保存 OPPO 广播消息体,返回 message_id。
+     */
+    private String saveOppoMessageContent(String authToken, CommonPushTask task, CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("title", task.getTitle());
+        form.add("content", task.getContent());
+        form.add("call_back_url", resolveCallbackUrl());
+        form.add("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("click_action_url", task.getJumpUrl());
+        }
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/save_message_content", form, null);
+        log.info("OPPO 保存广播消息体响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json == null || json.getJSONObject("data") == null) {
+            return null;
+        }
+        return json.getJSONObject("data").getString("message_id");
+    }
+
+    /**
+     * OPPO 广播下发,target_type=1 表示全量用户,返回 task_id。
+     */
+    private String broadcastOppoMessage(String authToken, String messageId) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("message_id", messageId);
+        form.add("target_type", "1");
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/broadcast", form, null);
+        log.info("OPPO 广播推送响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        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() {
+        return StringUtils.defaultIfBlank(commonPushProperties.getCallbackUrl(),
+                "https://frp-off.com:40279/alienStore/commonPushTaskUser/callback");
+    }
+
+    /**
+     * OPPO call_back_parameter 限 100 字符,仅传 pushTaskId + userId 短键,deviceId 由回执 registrationIds 带回。
+     */
+    private String buildCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        String json = param.toJSONString();
+        if (json.length() > OPPO_CALLBACK_PARAM_MAX_LEN) {
+            log.warn("OPPO call_back_parameter 超长({}), 降级为仅 pushTaskId/taskNo", json.length());
+            JSONObject minimal = new JSONObject(true);
+            if (task.getId() != null) {
+                minimal.put("p", task.getId());
+            } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+                String taskNo = task.getTaskNo().trim();
+                int maxTaskNoLen = OPPO_CALLBACK_PARAM_MAX_LEN - 7;
+                if (taskNo.length() > maxTaskNoLen) {
+                    taskNo = taskNo.substring(0, maxTaskNoLen);
+                }
+                minimal.put("t", taskNo);
+            }
+            json = minimal.toJSONString();
+        }
+        if (json.length() > OPPO_CALLBACK_PARAM_MAX_LEN) {
+            log.error("OPPO call_back_parameter 仍超长({}): {}", json.length(), json);
+        }
+        return json;
+    }
+
+    /**
+     * 三星推送(Samsung Push Platform / SPP),根据 regID 前缀选择区域 RQM 节点。
+     */
+    private boolean sendSamsung(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String appId = StringUtils.defaultIfBlank(credential.getString("appId"), credential.getString("appID"));
+        String appSecret = StringUtils.defaultIfBlank(credential.getString("appSecret"),
+                credential.getString("app_secret"));
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            log.warn("三星推送凭证不完整, deviceId={}", deviceId);
+            return false;
+        }
+        JSONObject body = new JSONObject();
+        body.put("regID", deviceId);
+        body.put("requestID", StringUtils.defaultIfBlank(task.getTaskNo(), "REQ" + System.currentTimeMillis()));
+        body.put("message", buildSamsungMessage(task));
+
+        Map<String, String> headers = new HashMap<>();
+        headers.put("appID", appId);
+        headers.put("appSecret", appSecret);
+
+        String url = resolveSamsungPushUrl(deviceId);
+        String resp = postJson(url, body.toJSONString(), headers);
+        log.info("三星推送响应: url={}, body={}", url, resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        return json != null && json.getIntValue("statusCode") == 1000;
+    }
+
+    private String buildSamsungMessage(CommonPushTask task) {
+        StringBuilder sb = new StringBuilder("action=ALERT");
+        if (StringUtils.isNotBlank(task.getTitle())) {
+            sb.append("&alertTitle=").append(urlEncode(task.getTitle()));
+        }
+        if (StringUtils.isNotBlank(task.getContent())) {
+            sb.append("&alertMessage=").append(urlEncode(task.getContent()));
+        }
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            sb.append("&uri=").append(urlEncode(task.getJumpUrl()));
+        }
+        return sb.toString();
+    }
+
+    private String resolveSamsungPushUrl(String regId) {
+        if (StringUtils.isBlank(regId) || regId.length() < 2) {
+            return "https://apchina.push.samsungosp.com.cn:8090/spp/pns/api/push";
+        }
+        switch (regId.substring(0, 2)) {
+            case "00":
+                return "https://useast.push.samsungosp.com:8090/spp/pns/api/push";
+            case "02":
+                return "https://apsoutheast.push.samsungosp.com:8090/spp/pns/api/push";
+            case "03":
+                return "https://euwest.push.samsungosp.com:8090/spp/pns/api/push";
+            case "04":
+                return "https://apnortheast.push.samsungosp.com:8090/spp/pns/api/push";
+            case "05":
+                return "https://apkorea.push.samsungosp.com:8090/spp/pns/api/push";
+            case "06":
+                return "https://apchina.push.samsungosp.com.cn:8090/spp/pns/api/push";
+            case "50":
+                return "https://useast.gateway.push.samsungosp.com:8090/spp/pns/api/push";
+            case "52":
+                return "https://apsoutheast.gateway.push.samsungosp.com:8090/spp/pns/api/push";
+            case "53":
+                return "https://euwest.gateway.push.samsungosp.com:8090/spp/pns/api/push";
+            case "54":
+                return "https://apnortheast.gateway.push.samsungosp.com:8090/spp/pns/api/push";
+            case "55":
+                return "https://apkorea.gateway.push.samsungosp.com:8090/spp/pns/api/push";
+            case "56":
+                return "https://apchina.gateway.push.samsungosp.com.cn:8090/spp/pns/api/push";
+            default:
+                return "https://apchina.push.samsungosp.com.cn:8090/spp/pns/api/push";
+        }
+    }
+
+    private String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (Exception e) {
+            return value;
+        }
+    }
+
+    private CommonPushVendorSendResult sendHuawei(JSONObject credential, CommonPushTask task, String deviceId) {
+        return sendHuaweiLike(credential, task, Collections.singletonList(deviceId));
+    }
+
+    private CommonPushVendorSendResult sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
+        return sendHonorLike(credential, task, Collections.singletonList(deviceId));
+    }
+
+    /**
+     * 华为 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 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 CommonPushVendorSendResult.fail();
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, pushTokens);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        // 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 CommonPushVendorSendResult sendHonorLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return CommonPushVendorSendResult.fail();
+        }
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            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 CommonPushVendorSendResult.fail();
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, pushTokens);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(HONOR_PUSH_API_BASE + "/v1/" + appId + "/sendMessage",
+                body.toJSONString(), headers);
+        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 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 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", 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;
+        }
+    }
+
+    /**
+     * 解析华为 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) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
+        tokenForm.add("grant_type", "client_credentials");
+        tokenForm.add("client_id", appId);
+        tokenForm.add("client_secret", appSecret);
+        String tokenResp = postForm(HONOR_AUTH_URL, tokenForm, null);
+        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+            log.warn("荣耀鉴权失败: {}", tokenResp);
+            return null;
+        }
+        return tokenJson.getString("access_token");
+    }
+
+    /**
+     * 华为/荣耀下行消息体: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);
+        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();
+        }
+        List<String> deviceIds = new ArrayList<>();
+        Set<String> exists = new HashSet<>();
+        for (CommonPushTargetDto target : targets) {
+            if (target == null || StringUtils.isBlank(target.getDeviceId())) {
+                continue;
+            }
+            String deviceId = target.getDeviceId().trim();
+            if (exists.add(deviceId)) {
+                deviceIds.add(deviceId);
+            }
+        }
+        return deviceIds;
+    }
+
+    private List<List<String>> partitionList(List<String> list, int batchSize) {
+        List<List<String>> partitions = new ArrayList<>();
+        if (list == null || list.isEmpty() || batchSize <= 0) {
+            return partitions;
+        }
+        for (int i = 0; i < list.size(); i += batchSize) {
+            partitions.add(list.subList(i, Math.min(i + batchSize, list.size())));
+        }
+        return partitions;
+    }
+
+    private CommonPushVendorSendResult sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
+        if (!apnsPushClient.validateCredential(credential)) {
+            log.warn("APNs 凭证不完整, deviceId={}", deviceId);
+            return CommonPushVendorSendResult.fail();
+        }
+        boolean ok = apnsPushClient.send(credential, task, deviceId);
+        return ok ? CommonPushVendorSendResult.ok(task.getTaskNo()) : CommonPushVendorSendResult.fail();
+    }
+
+    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;
+        }
+    }
+
+    private String buildPayload(CommonPushTask task) {
+        JSONObject payload = new JSONObject();
+        payload.put("title", task.getTitle());
+        payload.put("content", task.getContent());
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            payload.put("jumpUrl", task.getJumpUrl());
+        }
+        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();
+        httpHeaders.setContentType(MediaType.APPLICATION_JSON);
+        if (headers != null) {
+            headers.forEach(httpHeaders::set);
+        }
+        ResponseEntity<String> response = restTemplate.postForEntity(url, new HttpEntity<>(body, httpHeaders), String.class);
+        return response.getBody();
+    }
+
+    private String postForm(String url, MultiValueMap<String, String> form, Map<String, String> headers) {
+        RestTemplate restTemplate = buildRestTemplate();
+        HttpHeaders httpHeaders = new HttpHeaders();
+        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        if (headers != null) {
+            headers.forEach(httpHeaders::set);
+        }
+        ResponseEntity<String> response = restTemplate.postForEntity(url, new HttpEntity<>(form, httpHeaders), String.class);
+        return response.getBody();
+    }
+
+    private RestTemplate buildRestTemplate() {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(TIMEOUT_MS);
+        factory.setReadTimeout(TIMEOUT_MS);
+        return new RestTemplate(factory);
+    }
+
+    private String md5Hex(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}

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

@@ -0,0 +1,153 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushChannelConfig;
+import shop.alien.mapper.CommonPushChannelConfigMapper;
+import shop.alien.store.service.CommonPushChannelConfigService;
+import shop.alien.util.ip.IpConnectTestUtil;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushChannelConfigServiceImpl extends ServiceImpl<CommonPushChannelConfigMapper, CommonPushChannelConfig> implements CommonPushChannelConfigService {
+
+    private static final Map<String, String> CHANNEL_TEST_HOST = new HashMap<>();
+
+    static {
+        CHANNEL_TEST_HOST.put("apns", "api.push.apple.com");
+        CHANNEL_TEST_HOST.put("huawei", "push-api.cloud.huawei.com");
+        CHANNEL_TEST_HOST.put("xiaomi", "api.xmpush.xiaomi.com");
+        CHANNEL_TEST_HOST.put("oppo", "api.push.oppomobile.com");
+        CHANNEL_TEST_HOST.put("vivo", "api-push.vivo.com.cn");
+        CHANNEL_TEST_HOST.put("honor", "push-api.cloud.honor.com");
+        CHANNEL_TEST_HOST.put("samsung", "apchina.push.samsungosp.com.cn");
+    }
+
+    @Override
+    public R<String> add(CommonPushChannelConfig config) {
+        log.info("CommonPushChannelConfigServiceImpl.add, param={}", config);
+        boolean result = this.save(config);
+        return result ? R.success("新增成功") : R.fail("新增失败");
+    }
+
+    @Override
+    public R<String> deleteById(Long id) {
+        log.info("CommonPushChannelConfigServiceImpl.deleteById, id={}", id);
+        boolean result = this.removeById(id);
+        return result ? R.success("删除成功") : R.fail("删除失败");
+    }
+
+    @Override
+    public R<String> update(CommonPushChannelConfig config) {
+        log.info("CommonPushChannelConfigServiceImpl.update, param={}", config);
+        if (config.getId() == null) {
+            return R.fail("id不能为空");
+        }
+        boolean result = this.updateById(config);
+        return result ? R.success("更新成功") : R.fail("更新失败");
+    }
+
+    @Override
+    public R<CommonPushChannelConfig> getInfoById(Long id) {
+        log.info("CommonPushChannelConfigServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<CommonPushChannelConfig>> list(Integer pageNum, Integer pageSize, String channelCode, String platform, Integer enable) {
+        log.info("CommonPushChannelConfigServiceImpl.list, pageNum={}, pageSize={}, channelCode={}, platform={}, enable={}",
+                pageNum, pageSize, channelCode, platform, enable);
+        Page<CommonPushChannelConfig> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<CommonPushChannelConfig> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.isNotBlank(channelCode)) {
+            wrapper.eq(CommonPushChannelConfig::getChannelCode, channelCode);
+        }
+        if (StringUtils.isNotBlank(platform)) {
+            wrapper.eq(CommonPushChannelConfig::getPlatform, platform);
+        }
+        if (enable != null) {
+            wrapper.eq(CommonPushChannelConfig::getEnable, enable);
+        }
+        wrapper.orderByDesc(CommonPushChannelConfig::getUpdatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+
+    @Override
+    public R<String> toggleEnable(Long id, Integer enable) {
+        log.info("CommonPushChannelConfigServiceImpl.toggleEnable, id={}, enable={}", id, enable);
+        if (id == null) {
+            return R.fail("id不能为空");
+        }
+        if (enable == null || (enable != 0 && enable != 1)) {
+            return R.fail("enable只能为0(停用)或1(启用)");
+        }
+        CommonPushChannelConfig config = this.getById(id);
+        if (config == null) {
+            return R.fail("配置不存在");
+        }
+        config.setEnable(enable);
+        boolean result = this.updateById(config);
+        return result ? R.success(enable == 1 ? "启用成功" : "停用成功") : R.fail("操作失败");
+    }
+
+    @Override
+    public R<String> testConnect(Long id) {
+        log.info("CommonPushChannelConfigServiceImpl.testConnect, id={}", id);
+        if (id == null) {
+            return R.fail("id不能为空");
+        }
+        CommonPushChannelConfig config = this.getById(id);
+        if (config == null) {
+            return R.fail("配置不存在");
+        }
+        if (StringUtils.isBlank(config.getCredentialJson())) {
+            saveTestResult(config, false, "凭证未配置");
+            return R.fail("凭证未配置");
+        }
+        try {
+            JSONObject.parseObject(config.getCredentialJson());
+        } catch (Exception e) {
+            saveTestResult(config, false, "凭证JSON格式错误");
+            return R.fail("凭证JSON格式错误");
+        }
+        String host = resolveTestHost(config.getChannelCode());
+        if (host == null) {
+            saveTestResult(config, false, "不支持的渠道编码");
+            return R.fail("不支持的渠道编码");
+        }
+        boolean connected = Boolean.TRUE.equals(IpConnectTestUtil.ipTest(host, 443));
+        if (connected) {
+            saveTestResult(config, true, null);
+            return R.success("连通成功");
+        }
+        saveTestResult(config, false, "连通失败,请检查凭证或网络");
+        return R.fail("连通失败");
+    }
+
+    private void saveTestResult(CommonPushChannelConfig config, boolean success, String warnMsg) {
+        config.setLastTestTime(new Date());
+        config.setConnectStatus(success ? 1 : 0);
+        config.setWarnMsg(warnMsg);
+        this.updateById(config);
+    }
+
+    private String resolveTestHost(String channelCode) {
+        if (StringUtils.isBlank(channelCode)) {
+            return null;
+        }
+        return CHANNEL_TEST_HOST.get(channelCode.trim().toLowerCase());
+    }
+}

+ 65 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushReviewServiceImpl.java

@@ -0,0 +1,65 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushReview;
+import shop.alien.mapper.CommonPushReviewMapper;
+import shop.alien.store.service.CommonPushReviewService;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushReviewServiceImpl extends ServiceImpl<CommonPushReviewMapper, CommonPushReview> implements CommonPushReviewService {
+
+    @Override
+    public R<String> add(CommonPushReview review) {
+        log.info("CommonPushReviewServiceImpl.add, param={}", review);
+        boolean result = this.save(review);
+        return result ? R.success("新增成功") : R.fail("新增失败");
+    }
+
+    @Override
+    public R<String> deleteById(Long id) {
+        log.info("CommonPushReviewServiceImpl.deleteById, id={}", id);
+        boolean result = this.removeById(id);
+        return result ? R.success("删除成功") : R.fail("删除失败");
+    }
+
+    @Override
+    public R<String> update(CommonPushReview review) {
+        log.info("CommonPushReviewServiceImpl.update, param={}", review);
+        if (review.getId() == null) {
+            return R.fail("id不能为空");
+        }
+        boolean result = this.updateById(review);
+        return result ? R.success("更新成功") : R.fail("更新失败");
+    }
+
+    @Override
+    public R<CommonPushReview> getInfoById(Long id) {
+        log.info("CommonPushReviewServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<CommonPushReview>> list(Integer pageNum, Integer pageSize, Long pushTaskId, Integer reviewStatus) {
+        log.info("CommonPushReviewServiceImpl.list, pageNum={}, pageSize={}, pushTaskId={}, reviewStatus={}",
+                pageNum, pageSize, pushTaskId, reviewStatus);
+        Page<CommonPushReview> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<CommonPushReview> wrapper = new LambdaQueryWrapper<>();
+        if (pushTaskId != null) {
+            wrapper.eq(CommonPushReview::getPushTaskId, pushTaskId);
+        }
+        if (reviewStatus != null) {
+            wrapper.eq(CommonPushReview::getReviewStatus, reviewStatus);
+        }
+        wrapper.orderByDesc(CommonPushReview::getUpdatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+}

+ 576 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java

@@ -0,0 +1,576 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONArray;
+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.CommonPushTaskUser;
+import shop.alien.entity.store.LifeUser;
+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;
+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;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushSendServiceImpl implements CommonPushSendService {
+
+    /** targetType=1 时走厂商全量广播 API 的渠道(按顺序执行,互不影响) */
+    private static final List<String> FULL_BROADCAST_CHANNELS =
+            Arrays.asList("apns", "huawei", "honor", "xiaomi", "oppo", "vivo");
+
+    private final CommonPushChannelConfigMapper commonPushChannelConfigMapper;
+    private final LifeUserMapper lifeUserMapper;
+    private final CommonPushVendorHttpClient commonPushVendorHttpClient;
+    private final CommonPushTaskUserService commonPushTaskUserService;
+
+    /**
+     * 查询当前可参与下发的厂商渠道(enable=1、凭证 JSON 合法、未超日配额)。
+     */
+    @Override
+    public List<CommonPushChannelConfig> listSendableChannels() {
+        List<CommonPushChannelConfig> configs = commonPushChannelConfigMapper.selectList(
+                new LambdaQueryWrapper<CommonPushChannelConfig>()
+                        .eq(CommonPushChannelConfig::getEnable, 1)
+                        .orderByAsc(CommonPushChannelConfig::getPriorityLevel));
+        if (configs == null || configs.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<CommonPushChannelConfig> result = new ArrayList<>();
+        for (CommonPushChannelConfig config : configs) {
+            if (StringUtils.isBlank(config.getCredentialJson())) {
+                continue;
+            }
+            try {
+                JSONObject.parseObject(config.getCredentialJson());
+            } catch (Exception e) {
+                log.warn("渠道凭证非法, channelCode={}", config.getChannelCode());
+                continue;
+            }
+            if (isQuotaExceeded(config)) {
+                log.warn("渠道配额已满, channelCode={}, todayUsage={}, dailyQuota={}",
+                        config.getChannelCode(), config.getTodayUsage(), config.getDailyQuota());
+                continue;
+            }
+            result.add(config);
+        }
+        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) {
+            return Collections.emptyList();
+        }
+        if (task.getTargetType() == 1) {
+            return toTargetsFromUsers(lifeUserMapper.selectList(deviceIdNotBlankWrapper()));
+        }
+        if (StringUtils.isBlank(task.getTargetConfig())) {
+            return Collections.emptyList();
+        }
+        JSONObject targetConfig = JSONObject.parseObject(task.getTargetConfig());
+        if (targetConfig == null) {
+            return Collections.emptyList();
+        }
+        if (targetConfig.containsKey("deviceIds")) {
+            return buildTargetsByDeviceIds(targetConfig.getJSONArray("deviceIds"));
+        }
+        if (targetConfig.containsKey("userIds")) {
+            JSONArray userIds = targetConfig.getJSONArray("userIds");
+            if (userIds == null || userIds.isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<Integer> ids = userIds.toJavaList(Integer.class);
+            if (ids.isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<LifeUser> users = lifeUserMapper.selectList(
+                    deviceIdNotBlankWrapper().in(LifeUser::getId, ids));
+            return toTargetsFromUsers(users);
+        }
+        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);
+        return sendToTargets(task, targets);
+    }
+
+    @Override
+    public CommonPushSendResultDto sendTest(String deviceId, String title, String content,
+                                            String vendorChannel, String jumpUrl, String platform) {
+        CommonPushSendResultDto result = new CommonPushSendResultDto();
+        if (StringUtils.isBlank(deviceId)) {
+            result.setSuccess(false);
+            result.setMessage("deviceId不能为空");
+            return result;
+        }
+        String normalizedDeviceId = deviceId.trim();
+        CommonPushTask task = buildTestTask(title, content, jumpUrl, vendorChannel);
+        CommonPushTargetDto target = new CommonPushTargetDto();
+        target.setDeviceId(normalizedDeviceId);
+        target.setPlatform(normalizePlatform(platform));
+        LifeUser user = lifeUserMapper.selectOne(
+                new LambdaQueryWrapper<LifeUser>()
+                        .eq(LifeUser::getDeviceId, normalizedDeviceId)
+                        .last("LIMIT 1"));
+        if (user != null && user.getId() != null) {
+            target.setUserId(user.getId().longValue());
+        }
+        return sendToTargets(task, Collections.singletonList(target));
+    }
+
+    private CommonPushTask buildTestTask(String title, String content, String jumpUrl, String vendorChannel) {
+        CommonPushTask task = new CommonPushTask();
+        task.setTitle(StringUtils.defaultIfBlank(title, "测试推送"));
+        task.setContent(StringUtils.defaultIfBlank(content, "这是一条测试推送消息"));
+        task.setJumpUrl(jumpUrl);
+        task.setTaskNo("TEST" + System.currentTimeMillis());
+        if (StringUtils.isNotBlank(vendorChannel)) {
+            JSONObject cfg = new JSONObject();
+            cfg.put("vendorChannel", vendorChannel.trim());
+            task.setTargetConfig(cfg.toJSONString());
+        }
+        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();
+        if (channels.isEmpty()) {
+            result.setSuccess(false);
+            result.setMessage("无可用推送渠道,请检查渠道配置是否启用且凭证完整");
+            return result;
+        }
+        Map<String, CommonPushChannelConfig> channelMap = channels.stream()
+                .collect(Collectors.toMap(c -> StringUtils.lowerCase(c.getChannelCode()), c -> c, (a, b) -> a));
+
+        boolean fullBroadcast = Integer.valueOf(1).equals(task.getTargetType());
+        boolean anyFullBroadcast = fullBroadcast && FULL_BROADCAST_CHANNELS.stream().anyMatch(channelMap::containsKey);
+        if ((targets == null || targets.isEmpty()) && !anyFullBroadcast) {
+            result.setSuccess(false);
+            result.setMessage("未找到可推送的目标设备");
+            return result;
+        }
+        if (targets == null) {
+            targets = Collections.emptyList();
+        }
+
+        Map<String, List<CommonPushTargetDto>> grouped = groupTargetsByChannel(targets, channelMap, task.getTargetConfig());
+
+        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;
+                // 调用对应厂商全量/广播接口(如 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);
+            }
+        }
+
+        for (Map.Entry<String, List<CommonPushTargetDto>> entry : grouped.entrySet()) {
+            if (broadcastHandledChannels.contains(entry.getKey())) {
+                continue;
+            }
+            CommonPushChannelConfig channelConfig = channelMap.get(entry.getKey());
+            if (channelConfig == null) {
+                failedCount += entry.getValue().size();
+                result.getFailedTargets().addAll(entry.getValue());
+                continue;
+            }
+            for (CommonPushTargetDto target : entry.getValue()) {
+                CommonPushVendorSendResult sendResult;
+                try {
+                    sendResult = commonPushVendorHttpClient.send(channelConfig, task, target);
+                } catch (Exception e) {
+                    log.error("渠道单推异常, channelCode={}, deviceId={}", entry.getKey(), target.getDeviceId(), e);
+                    sendResult = CommonPushVendorSendResult.fail();
+                }
+                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);
+        if (sentCount > 0 && failedCount > 0) {
+            result.setMessage("推送部分完成,成功 " + sentCount + ",失败 " + failedCount);
+        } else {
+            result.setMessage(sentCount > 0 ? "推送完成" : "推送失败,请检查渠道凭证与设备 token");
+        }
+        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,
+                                         CommonPushChannelConfig channelConfig) {
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        if (ok) {
+            int broadcastCount = channelTargets.isEmpty()
+                    ? defaultCount(task.getEstimatedCount())
+                    : channelTargets.size();
+            if (channelTargets.isEmpty()) {
+                CommonPushTargetDto broadcastTarget = new CommonPushTargetDto();
+                broadcastTarget.setPlatform("android");
+                broadcastTarget.setBroadcast(true);
+                result.getSentTargets().add(broadcastTarget);
+            } else {
+                result.getSentTargets().addAll(channelTargets);
+            }
+            updateChannelUsageSafely(channelConfig, channelCode);
+            return broadcastCount;
+        }
+        if (!channelTargets.isEmpty()) {
+            result.getFailedTargets().addAll(channelTargets);
+        }
+        return 0;
+    }
+
+    /** 按渠道编码调用对应厂商全量/广播推送 API(apns/huawei/honor/xiaomi/oppo/vivo)。 */
+    private CommonPushVendorSendResult invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
+                                          List<CommonPushTargetDto> channelTargets) {
+        try {
+            switch (channelCode) {
+                case "apns":
+                    return commonPushVendorHttpClient.sendApnsBroadcast(config, task, channelTargets);
+                case "huawei":
+                    return commonPushVendorHttpClient.sendHuaweiBroadcast(config, task, channelTargets);
+                case "honor":
+                    return commonPushVendorHttpClient.sendHonorBroadcast(config, task, channelTargets);
+                case "xiaomi":
+                    return commonPushVendorHttpClient.sendXiaomiBroadcast(config, task);
+                case "oppo":
+                    return commonPushVendorHttpClient.sendOppoBroadcast(config, task);
+                case "vivo":
+                    return commonPushVendorHttpClient.sendVivoBroadcast(config, task);
+                default:
+                    return CommonPushVendorSendResult.fail();
+            }
+        } catch (Exception e) {
+            log.error("全量广播推送异常, channelCode={}, taskNo={}", channelCode, task.getTaskNo(), e);
+            return CommonPushVendorSendResult.fail();
+        }
+    }
+
+    /** 更新渠道 today_usage,失败仅打日志不中断推送。 */
+    private void updateChannelUsageSafely(CommonPushChannelConfig config, String channelCode) {
+        try {
+            updateChannelUsage(config);
+        } catch (Exception e) {
+            log.warn("更新渠道用量失败, channelCode={}", channelCode, e);
+        }
+    }
+
+    /** 全量广播失败时的失败条数(无设备时分母按 1 计)。 */
+    private int countFullBroadcastFailure(boolean ok, String channelCode,
+                                          Map<String, List<CommonPushTargetDto>> grouped) {
+        if (ok) {
+            return 0;
+        }
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        return channelTargets.isEmpty() ? 1 : channelTargets.size();
+    }
+
+    /**
+     * 推送完成后写入 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) {
+            return;
+        }
+        Set<String> sentDeviceIds = sendResult.getSentTargets().stream()
+                .map(CommonPushTargetDto::getDeviceId)
+                .collect(Collectors.toSet());
+        List<CommonPushTargetDto> allTargets = new ArrayList<>();
+        allTargets.addAll(sendResult.getSentTargets());
+        allTargets.addAll(sendResult.getFailedTargets());
+        for (CommonPushTargetDto target : allTargets) {
+            if (Boolean.TRUE.equals(target.getBroadcast()) || StringUtils.isBlank(target.getDeviceId())) {
+                continue;
+            }
+            CommonPushTaskUser taskUser = new CommonPushTaskUser();
+            taskUser.setPushTaskId(pushTaskId);
+            taskUser.setUserId(target.getUserId());
+            taskUser.setDeviceId(target.getDeviceId());
+            taskUser.setRelType(1);
+            taskUser.setStatus(sentDeviceIds.contains(target.getDeviceId()) ? 2 : 0);
+            taskUser.setShowInfo(0);
+            taskUser.setUserAdd(0);
+            commonPushTaskUserService.save(taskUser);
+        }
+        for (CommonPushTargetDto target : sendResult.getSentTargets()) {
+            if (!Boolean.TRUE.equals(target.getBroadcast())) {
+                continue;
+            }
+            CommonPushTaskUser taskUser = new CommonPushTaskUser();
+            taskUser.setPushTaskId(pushTaskId);
+            taskUser.setRelType(1);
+            taskUser.setStatus(2);
+            taskUser.setShowInfo(0);
+            taskUser.setUserAdd(0);
+            commonPushTaskUserService.save(taskUser);
+        }
+    }
+
+    /**
+     * 按 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();
+        }
+        List<String> idList = new ArrayList<>();
+        Set<String> exists = new HashSet<>();
+        for (int i = 0; i < deviceIds.size(); i++) {
+            String deviceId = deviceIds.getString(i);
+            if (StringUtils.isNotBlank(deviceId) && exists.add(deviceId.trim())) {
+                idList.add(deviceId.trim());
+            }
+        }
+        if (idList.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<LifeUser> users = lifeUserMapper.selectList(
+                deviceIdNotBlankWrapper().in(LifeUser::getDeviceId, idList));
+        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();
+        }
+        List<CommonPushTargetDto> targets = new ArrayList<>();
+        for (LifeUser user : users) {
+            if (user == null || StringUtils.isBlank(user.getDeviceId())) {
+                continue;
+            }
+            CommonPushTargetDto target = new CommonPushTargetDto();
+            target.setUserId(user.getId() != null ? user.getId().longValue() : null);
+            target.setDeviceId(user.getDeviceId());
+            // 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) {
+        String preferredChannel = null;
+        if (StringUtils.isNotBlank(targetConfigJson)) {
+            JSONObject cfg = JSONObject.parseObject(targetConfigJson);
+            if (cfg != null && StringUtils.isNotBlank(cfg.getString("vendorChannel"))) {
+                preferredChannel = StringUtils.lowerCase(cfg.getString("vendorChannel"));
+            }
+        }
+        Map<String, List<CommonPushTargetDto>> grouped = new HashMap<>();
+        for (CommonPushTargetDto target : targets) {
+            String channelCode = resolveChannelCode(target, channelMap, preferredChannel);
+            if (channelCode == null) {
+                continue;
+            }
+            grouped.computeIfAbsent(channelCode, k -> new ArrayList<>()).add(target);
+        }
+        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) {
+        if (StringUtils.isNotBlank(preferredChannel) && channelMap.containsKey(preferredChannel)) {
+            return preferredChannel;
+        }
+        String platform = StringUtils.lowerCase(StringUtils.defaultString(target.getPlatform()));
+        if (platform.contains("ios") || "iphone".equals(platform)) {
+            return channelMap.containsKey("apns") ? "apns" : null;
+        }
+        List<String> androidChannels = channelMap.keySet().stream()
+                .filter(code -> !"apns".equals(code))
+                .sorted(Comparator.comparingInt(this::channelPriority))
+                .collect(Collectors.toList());
+        return androidChannels.isEmpty() ? null : androidChannels.get(0);
+    }
+
+    /** Android 厂商渠道自动选择时的排序权重(越小越优先)。 */
+    private int channelPriority(String channelCode) {
+        switch (channelCode) {
+            case "huawei":
+                return 1;
+            case "honor":
+                return 2;
+            case "xiaomi":
+                return 3;
+            case "oppo":
+                return 4;
+            case "vivo":
+                return 5;
+            case "samsung":
+                return 6;
+            default:
+                return 99;
+        }
+    }
+
+    private String normalizePlatform(String platform) {
+        if (StringUtils.isBlank(platform)) {
+            return "android";
+        }
+        return StringUtils.lowerCase(platform.trim());
+    }
+
+    /** 判断渠道是否已达 daily_quota 上限(0 表示不限)。 */
+    private boolean isQuotaExceeded(CommonPushChannelConfig config) {
+        if (config.getDailyQuota() == null || config.getDailyQuota() <= 0) {
+            return false;
+        }
+        int usage = config.getTodayUsage() == null ? 0 : config.getTodayUsage();
+        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);
+        commonPushChannelConfigMapper.updateById(config);
+    }
+}

+ 66 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskNumServiceImpl.java

@@ -0,0 +1,66 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTaskNum;
+import shop.alien.mapper.CommonPushTaskNumMapper;
+import shop.alien.store.service.CommonPushTaskNumService;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushTaskNumServiceImpl extends ServiceImpl<CommonPushTaskNumMapper, CommonPushTaskNum> implements CommonPushTaskNumService {
+
+    @Override
+    public R<String> add(CommonPushTaskNum taskNum) {
+        log.info("CommonPushTaskNumServiceImpl.add, param={}", taskNum);
+        boolean result = this.save(taskNum);
+        return result ? R.success("新增成功") : R.fail("新增失败");
+    }
+
+    @Override
+    public R<String> deleteById(Long id) {
+        log.info("CommonPushTaskNumServiceImpl.deleteById, id={}", id);
+        boolean result = this.removeById(id);
+        return result ? R.success("删除成功") : R.fail("删除失败");
+    }
+
+    @Override
+    public R<String> update(CommonPushTaskNum taskNum) {
+        log.info("CommonPushTaskNumServiceImpl.update, param={}", taskNum);
+        if (taskNum.getId() == null) {
+            return R.fail("id不能为空");
+        }
+        boolean result = this.updateById(taskNum);
+        return result ? R.success("更新成功") : R.fail("更新失败");
+    }
+
+    @Override
+    public R<CommonPushTaskNum> getInfoById(Long id) {
+        log.info("CommonPushTaskNumServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<CommonPushTaskNum>> list(Integer pageNum, Integer pageSize, Long pushTaskId, String phoneType) {
+        log.info("CommonPushTaskNumServiceImpl.list, pageNum={}, pageSize={}, pushTaskId={}, phoneType={}",
+                pageNum, pageSize, pushTaskId, phoneType);
+        Page<CommonPushTaskNum> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<CommonPushTaskNum> wrapper = new LambdaQueryWrapper<>();
+        if (pushTaskId != null) {
+            wrapper.eq(CommonPushTaskNum::getPushTaskId, pushTaskId);
+        }
+        if (StringUtils.isNotBlank(phoneType)) {
+            wrapper.eq(CommonPushTaskNum::getPhoneType, phoneType);
+        }
+        wrapper.orderByDesc(CommonPushTaskNum::getUpdatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+}

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

@@ -0,0 +1,624 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushReview;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.CommonPushTaskStatus;
+import shop.alien.entity.store.dto.CommonPushTaskStatsDto;
+import shop.alien.entity.store.vo.*;
+import shop.alien.mapper.CommonPushTaskMapper;
+import shop.alien.store.dto.CommonPushSendResultDto;
+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;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper, CommonPushTask> implements CommonPushTaskService {
+
+    private static final String[] WEEKDAY_NAMES = {"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
+
+    /** 审核表状态:审核通过 */
+    private static final int REVIEW_STATUS_APPROVED = 2;
+    /** 审核表状态:审核驳回 */
+    private static final int REVIEW_STATUS_REJECTED = 3;
+
+    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) {
+        log.info("CommonPushTaskServiceImpl.add, param={}", task);
+        R<String> validated = validateTaskForAdd(task);
+        if (validated != null) {
+            return validated;
+        }
+        fillTaskDefaults(task);
+        task.setStatus(CommonPushTaskStatus.PENDING_REVIEW);
+
+        String rejectReason = auditPushContent(task);
+        boolean auditPassed = rejectReason == null;
+        task.setStatus(auditPassed ? CommonPushTaskStatus.REVIEW_PASSED : CommonPushTaskStatus.REJECTED);
+
+        if (!this.save(task)) {
+            return R.fail("新增失败");
+        }
+        savePushReview(task.getId(), auditPassed, rejectReason);
+
+        if (!auditPassed) {
+            return R.fail("审核未通过:" + rejectReason);
+        }
+
+        if (Integer.valueOf(1).equals(task.getSendType())) {
+            R<String> sendResult = doSendTask(task);
+            return sendResult;
+        }
+        if (Integer.valueOf(2).equals(task.getSendType())) {
+            return R.success("新增成功,任务将于 " + formatDateTime(task.getScheduledAt()) + " 发送");
+        }
+        return R.success("新增成功");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R<Long> saveDraft(CommonPushTask task) {
+        log.info("CommonPushTaskServiceImpl.saveDraft, param={}", task);
+        if (task == null) {
+            return R.fail("任务不能为空");
+        }
+        fillTaskDefaults(task);
+        task.setStatus(CommonPushTaskStatus.DRAFT);
+
+        if (task.getId() == null) {
+            if (!this.save(task)) {
+                return R.fail("保存草稿失败");
+            }
+            return R.data(task.getId(), "保存草稿成功");
+        }
+
+        CommonPushTask existing = this.getById(task.getId());
+        if (existing == null) {
+            return R.fail("草稿不存在");
+        }
+        if (!Integer.valueOf(CommonPushTaskStatus.DRAFT).equals(existing.getStatus())) {
+            return R.fail("仅草稿状态的任务可更新草稿");
+        }
+        task.setStatus(CommonPushTaskStatus.DRAFT);
+        if (!this.updateById(task)) {
+            return R.fail("保存草稿失败");
+        }
+        return R.data(task.getId(), "保存草稿成功");
+    }
+
+    @Override
+    public int executeScheduledPushTasks() {
+        Date now = new Date();
+        markExpiredScheduledTasks(now);
+
+        List<CommonPushTask> dueTasks = this.list(new LambdaQueryWrapper<CommonPushTask>()
+                .eq(CommonPushTask::getSendType, 2)
+                .eq(CommonPushTask::getStatus, CommonPushTaskStatus.REVIEW_PASSED)
+                .le(CommonPushTask::getScheduledAt, now)
+                .and(w -> w.isNull(CommonPushTask::getExpireAt).or().gt(CommonPushTask::getExpireAt, now))
+                .orderByAsc(CommonPushTask::getScheduledAt)
+                .orderByAsc(CommonPushTask::getId));
+
+        if (dueTasks == null || dueTasks.isEmpty()) {
+            return 0;
+        }
+
+        int successCount = 0;
+        for (CommonPushTask task : dueTasks) {
+            try {
+                R<String> result = doSendTask(task);
+                if (R.isSuccess(result)) {
+                    successCount++;
+                }
+            } catch (Exception e) {
+                log.error("定时推送任务执行异常, taskId={}, taskNo={}", task.getId(), task.getTaskNo(), e);
+            }
+        }
+        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)
+                .eq(CommonPushTask::getStatus, CommonPushTaskStatus.REVIEW_PASSED)
+                .le(CommonPushTask::getScheduledAt, now)
+                .isNotNull(CommonPushTask::getExpireAt)
+                .lt(CommonPushTask::getExpireAt, now));
+        if (expiredTasks == null || expiredTasks.isEmpty()) {
+            return;
+        }
+        for (CommonPushTask task : expiredTasks) {
+            CommonPushTask update = new CommonPushTask();
+            update.setId(task.getId());
+            update.setStatus(CommonPushTaskStatus.FAILED);
+            this.updateById(update);
+            log.warn("定时推送任务已过期未发送, taskId={}, taskNo={}, expireAt={}",
+                    task.getId(), task.getTaskNo(), task.getExpireAt());
+        }
+    }
+
+    /**
+     * 执行推送并回写任务状态。
+     * <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 {
+            sendResult = commonPushSendService.send(task);
+        } catch (Exception e) {
+            log.error("推送任务执行异常, taskId={}, taskNo={}", task.getId(), task.getTaskNo(), e);
+            task.setStatus(CommonPushTaskStatus.FAILED);
+            this.updateById(task);
+            return R.fail("推送失败:" + e.getMessage());
+        }
+        if (!sendResult.isSuccess()) {
+            task.setStatus(CommonPushTaskStatus.FAILED);
+            this.updateById(task);
+            return R.fail(sendResult.getMessage());
+        }
+        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 "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
+    }
+
+    @Override
+    public R<CommonPushSendResultDto> sendTest(CommonPushTestSendDto dto) {
+        log.info("CommonPushTaskServiceImpl.sendTest, param={}", dto);
+        if (dto == null) {
+            return R.fail("请求参数不能为空");
+        }
+        CommonPushSendResultDto result = commonPushSendService.sendTest(
+                dto.getDeviceId(),
+                dto.getTitle(),
+                dto.getContent(),
+                dto.getVendorChannel(),
+                dto.getJumpUrl(),
+                dto.getPlatform());
+        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("任务不能为空");
+        }
+        if (StringUtils.isBlank(task.getTitle())) {
+            return R.fail("推送标题不能为空");
+        }
+        if (StringUtils.isBlank(task.getContent())) {
+            return R.fail("推送内容不能为空");
+        }
+        if (task.getSendType() == null) {
+            return R.fail("发送方式不能为空");
+        }
+        if (Integer.valueOf(1).equals(task.getSendType()) && task.getTargetType() == null) {
+            return R.fail("立即发送时目标类型不能为空");
+        }
+        if (Integer.valueOf(2).equals(task.getSendType())) {
+            if (task.getTargetType() == null) {
+                return R.fail("定时发送时目标类型不能为空");
+            }
+            if (task.getScheduledAt() == null) {
+                return R.fail("定时发送时执行时间不能为空");
+            }
+            if (!task.getScheduledAt().after(new Date())) {
+                return R.fail("执行时间必须晚于当前时间");
+            }
+            if (task.getExpireAt() != null && !task.getExpireAt().after(task.getScheduledAt())) {
+                return R.fail("过期时间必须晚于执行时间");
+            }
+        }
+        return null;
+    }
+
+    /**
+     * AI 审核推送标题与正文(敏感词/违规内容)。
+     *
+     * @return 驳回原因;通过时返回 null
+     */
+    private String auditPushContent(CommonPushTask task) {
+        AiContentModerationUtil.AuditResult titleAudit =
+                aiContentModerationUtil.auditContent(task.getTitle(), null);
+        if (!titleAudit.isPassed()) {
+            log.warn("推送标题审核未通过: {}", titleAudit.getFailureReason());
+            return "推送标题:" + titleAudit.getFailureReason();
+        }
+        AiContentModerationUtil.AuditResult contentAudit =
+                aiContentModerationUtil.auditContent(task.getContent(), null);
+        if (!contentAudit.isPassed()) {
+            log.warn("推送内容审核未通过: {}", contentAudit.getFailureReason());
+            return "推送内容:" + contentAudit.getFailureReason();
+        }
+        return null;
+    }
+
+    /** 写入 common_push_review 审核记录(通过/驳回及原因)。 */
+    private void savePushReview(Long pushTaskId, boolean auditPassed, String rejectReason) {
+        CommonPushReview review = new CommonPushReview();
+        review.setPushTaskId(pushTaskId);
+        review.setReviewAt(new Date());
+        if (auditPassed) {
+            review.setReviewStatus(REVIEW_STATUS_APPROVED);
+            review.setRejectCount(0);
+        } else {
+            review.setReviewStatus(REVIEW_STATUS_REJECTED);
+            review.setRejectReason(rejectReason);
+            review.setRejectCount(1);
+        }
+        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())
+                    + String.format("%04d", (int) (Math.random() * 10000)));
+        }
+        if (task.getEstimatedCount() == null) {
+            task.setEstimatedCount(0);
+        }
+        if (task.getActualCount() == null) {
+            task.setActualCount(0);
+        }
+    }
+
+    @Override
+    public R<String> deleteById(Long id) {
+        log.info("CommonPushTaskServiceImpl.deleteById, id={}", id);
+        boolean result = this.removeById(id);
+        return result ? R.success("删除成功") : R.fail("删除失败");
+    }
+
+    @Override
+    public R<String> update(CommonPushTask task) {
+        log.info("CommonPushTaskServiceImpl.update, param={}", task);
+        if (task.getId() == null) {
+            return R.fail("id不能为空");
+        }
+        boolean result = this.updateById(task);
+        return result ? R.success("更新成功") : R.fail("更新失败");
+    }
+
+    @Override
+    public R<CommonPushTask> getInfoById(Long id) {
+        log.info("CommonPushTaskServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<CommonPushTask>> list(Integer pageNum, Integer pageSize, String taskNo, String title, String status, Integer pushType) {
+        log.info("CommonPushTaskServiceImpl.list, pageNum={}, pageSize={}, taskNo={}, title={}, status={}, pushType={}",
+                pageNum, pageSize, taskNo, title, status, pushType);
+        Page<CommonPushTask> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<CommonPushTask> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.isNotBlank(taskNo)) {
+            wrapper.eq(CommonPushTask::getTaskNo, taskNo);
+        }
+        if (StringUtils.isNotBlank(title)) {
+            wrapper.like(CommonPushTask::getTitle, title);
+        }
+        if (StringUtils.isNotBlank(status)) {
+            wrapper.eq(CommonPushTask::getStatus, status);
+        }
+        if (pushType != null) {
+            wrapper.eq(CommonPushTask::getPushType, pushType);
+        }
+        wrapper.orderByDesc(CommonPushTask::getUpdatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+
+    @Override
+    public R<CommonPushTaskStatisticsPageVo> statisticsList(Integer pageNum, Integer pageSize, String taskNo, String keyword,
+                                                            String status, Integer pushType, String channel,
+                                                            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, queryStartTime, queryEndTime);
+
+        CommonPushTaskStatsDto summaryStats = baseMapper.selectStatisticsSummary(taskNo, keyword, status, pushType, channel, queryStartTime, queryEndTime);
+
+        CommonPushTaskStatisticsPageVo result = new CommonPushTaskStatisticsPageVo();
+        result.setSummary(buildUserStatsSummary(summaryStats != null ? summaryStats : emptyUserStats()));
+        result.setPage(statsPage.convert(this::toStatisticsItem));
+        log.info("CommonPushTaskServiceImpl.statisticsList, result={}", result);
+        return R.data(result);
+    }
+
+    @Override
+    public R<CommonPushFunnelVo> funnel(String phoneType, Date startTime, Date endTime) {
+        log.info("CommonPushTaskServiceImpl.funnel, phoneType={}, startTime={}, endTime={}", phoneType, startTime, endTime);
+        CommonPushTaskStatsDto stats = baseMapper.selectUserStats(startTime, endTime, phoneType);
+        if (stats == null) {
+            stats = emptyUserStats();
+        }
+
+        CommonPushFunnelVo funnelVo = new CommonPushFunnelVo();
+        funnelVo.setPhoneType(phoneType);
+        funnelVo.setSteps(buildFunnelSteps(stats));
+        log.info("CommonPushTaskServiceImpl.funnel, result={}", funnelVo);
+        return R.data(funnelVo);
+    }
+
+    @Override
+    public R<CommonPushReportVo> report(Integer reportType) {
+        log.info("CommonPushTaskServiceImpl.report, reportType={}", reportType);
+        if (reportType == null) {
+            reportType = 1;
+        }
+        Date reportDate = truncateToDay(new Date());
+
+        Date[] range = resolveReportRange(reportType, reportDate);
+        Date startTime = range[0];
+        Date endTime = range[1];
+
+        CommonPushTaskStatsDto stats = baseMapper.selectUserStats(startTime, endTime, null);
+        if (stats == null) {
+            stats = emptyUserStats();
+        }
+
+        CommonPushReportVo reportVo = new CommonPushReportVo();
+        reportVo.setReportType(reportType);
+        reportVo.setReportDate(reportDate);
+        reportVo.setStartTime(startTime);
+        reportVo.setEndTime(endTime);
+        reportVo.setReportTitle(buildReportTitle(reportType, reportDate));
+        reportVo.setSummary(buildUserStatsSummary(stats));
+        reportVo.setTopList(buildTopList(startTime, endTime));
+        log.info("CommonPushTaskServiceImpl.report, result={}", reportVo);
+        return R.data(reportVo);
+    }
+
+    private CommonPushTaskStatisticsItemVo toStatisticsItem(CommonPushTaskStatsDto dto) {
+        CommonPushTaskStatisticsItemVo item = new CommonPushTaskStatisticsItemVo();
+        item.setId(dto.getId());
+        item.setTaskNo(dto.getTaskNo());
+        item.setTitle(dto.getTitle());
+        item.setPushType(dto.getPushType());
+        item.setStatus(dto.getStatus());
+        item.setChannels(dto.getChannels());
+        item.setEstimatedCount(dto.getEstimatedCount());
+        item.setCreatedTime(dto.getCreatedTime());
+        item.setSentCount(defaultCount(dto.getSentCount()));
+        item.setDeliveredCount(defaultCount(dto.getDeliveredCount()));
+        item.setClickCount(defaultCount(dto.getClickCount()));
+        item.setPushRate(calcRate(item.getSentCount(), dto.getEstimatedCount() != null ? dto.getEstimatedCount().longValue() : 0L));
+        item.setDeliveryRate(calcRate(item.getDeliveredCount(), item.getSentCount()));
+        item.setClickRate(calcRate(item.getClickCount(), item.getDeliveredCount()));
+        return item;
+    }
+
+    private CommonPushStatisticsSummaryVo buildUserStatsSummary(CommonPushTaskStatsDto stats) {
+        return buildSummary(defaultCount(stats.getSentCount()), defaultCount(stats.getDeliveredCount()), defaultCount(stats.getClickCount()));
+    }
+
+    private CommonPushStatisticsSummaryVo buildSummary(long sentCount, long deliveredCount, long clickCount) {
+        CommonPushStatisticsSummaryVo summary = new CommonPushStatisticsSummaryVo();
+        summary.setPushVolume(sentCount);
+        summary.setDeliveredCount(deliveredCount);
+        summary.setClickCount(clickCount);
+        summary.setDeliveryRate(calcRate(deliveredCount, sentCount));
+        summary.setClickRate(calcRate(clickCount, deliveredCount));
+        summary.setConversionRate(calcRate(clickCount, sentCount));
+        return summary;
+    }
+
+    private List<CommonPushFunnelStepVo> buildFunnelSteps(CommonPushTaskStatsDto stats) {
+        long actualSend = defaultCount(stats.getSentCount());
+        long arrived = defaultCount(stats.getDeliveredCount());
+        long show = defaultCount(stats.getShowCount());
+        long click = defaultCount(stats.getClickCount());
+
+        List<CommonPushFunnelStepVo> steps = new ArrayList<>();
+
+        CommonPushFunnelStepVo actual = new CommonPushFunnelStepVo();
+        actual.setStepCode("actual");
+        actual.setStepName("实际发送");
+        actual.setCount(actualSend);
+        actual.setConversionRate(BigDecimal.valueOf(100));
+        steps.add(actual);
+
+        CommonPushFunnelStepVo arrivedStep = new CommonPushFunnelStepVo();
+        arrivedStep.setStepCode("arrived");
+        arrivedStep.setStepName("抵达设备");
+        arrivedStep.setCount(arrived);
+        arrivedStep.setConversionRate(calcRate(arrived, actualSend));
+        steps.add(arrivedStep);
+
+        CommonPushFunnelStepVo showStep = new CommonPushFunnelStepVo();
+        showStep.setStepCode("show");
+        showStep.setStepName("展示通知");
+        showStep.setCount(show);
+        showStep.setConversionRate(calcRate(show, arrived));
+        steps.add(showStep);
+
+        CommonPushFunnelStepVo clickStep = new CommonPushFunnelStepVo();
+        clickStep.setStepCode("click");
+        clickStep.setStepName("用户点击");
+        clickStep.setCount(click);
+        clickStep.setConversionRate(calcRate(click, arrived));
+        steps.add(clickStep);
+
+        return steps;
+    }
+
+    private CommonPushTaskStatsDto emptyUserStats() {
+        CommonPushTaskStatsDto stats = new CommonPushTaskStatsDto();
+        stats.setSentCount(0L);
+        stats.setDeliveredCount(0L);
+        stats.setShowCount(0L);
+        stats.setClickCount(0L);
+        return stats;
+    }
+
+    private List<CommonPushReportTopItemVo> buildTopList(Date startTime, Date endTime) {
+        List<CommonPushTaskStatsDto> topStats = baseMapper.selectTopByClickRate(startTime, endTime, 5);
+        List<CommonPushReportTopItemVo> topList = new ArrayList<>();
+        if (topStats == null) {
+            return topList;
+        }
+        int rank = 1;
+        for (CommonPushTaskStatsDto dto : topStats) {
+            CommonPushReportTopItemVo item = new CommonPushReportTopItemVo();
+            item.setRank(rank++);
+            item.setPushTaskId(dto.getId());
+            item.setTitle(dto.getTitle());
+            item.setDeliveredCount(defaultCount(dto.getDeliveredCount()));
+            item.setClickCount(defaultCount(dto.getClickCount()));
+            item.setClickRate(calcRate(item.getClickCount(), item.getDeliveredCount()));
+            topList.add(item);
+        }
+        return topList;
+    }
+
+    private String buildReportTitle(Integer reportType, Date reportDate) {
+        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+        String dateStr = dateFormat.format(reportDate);
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(reportDate);
+        String weekday = WEEKDAY_NAMES[calendar.get(Calendar.DAY_OF_WEEK) - 1];
+        if (reportType == 2) {
+            return "推送周报 — " + dateStr;
+        }
+        if (reportType == 3) {
+            SimpleDateFormat monthFormat = new SimpleDateFormat("yyyy-MM");
+            return "推送月报 — " + monthFormat.format(reportDate);
+        }
+        return "推送日报 — " + dateStr + " (" + weekday + ")";
+    }
+
+    private Date[] resolveReportRange(Integer reportType, Date reportDate) {
+        Calendar start = Calendar.getInstance();
+        start.setTime(reportDate);
+        start.set(Calendar.HOUR_OF_DAY, 0);
+        start.set(Calendar.MINUTE, 0);
+        start.set(Calendar.SECOND, 0);
+        start.set(Calendar.MILLISECOND, 0);
+
+        Calendar end = (Calendar) start.clone();
+        if (reportType == 2) {
+            int dayOfWeek = start.get(Calendar.DAY_OF_WEEK);
+            int diffToMonday = (dayOfWeek + 5) % 7;
+            start.add(Calendar.DAY_OF_MONTH, -diffToMonday);
+            end.setTime(start.getTime());
+            end.add(Calendar.DAY_OF_MONTH, 7);
+        } else if (reportType == 3) {
+            start.set(Calendar.DAY_OF_MONTH, 1);
+            end.setTime(start.getTime());
+            end.add(Calendar.MONTH, 1);
+        } else {
+            end.add(Calendar.DAY_OF_MONTH, 1);
+        }
+        return new Date[]{start.getTime(), end.getTime()};
+    }
+
+    private Date truncateToDay(Date date) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(date);
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        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;
+    }
+
+    private BigDecimal calcRate(long numerator, long denominator) {
+        if (denominator <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return BigDecimal.valueOf(numerator)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(denominator), 1, RoundingMode.HALF_UP);
+    }
+}

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

+ 304 - 0
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskUserServiceImpl.java

@@ -0,0 +1,304 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.CommonPushTask;
+import shop.alien.entity.store.CommonPushTaskStatus;
+import shop.alien.entity.store.CommonPushTaskUser;
+import shop.alien.mapper.CommonPushTaskMapper;
+import shop.alien.mapper.CommonPushTaskUserMapper;
+import shop.alien.store.dto.CommonPushTaskUserCallbackDto;
+import shop.alien.store.service.CommonPushTaskUserService;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CommonPushTaskUserServiceImpl extends ServiceImpl<CommonPushTaskUserMapper, CommonPushTaskUser> implements CommonPushTaskUserService {
+
+    private final CommonPushTaskMapper commonPushTaskMapper;
+
+    @Override
+    public R<String> add(CommonPushTaskUser taskUser) {
+        log.info("CommonPushTaskUserServiceImpl.add, param={}", taskUser);
+        boolean result = this.save(taskUser);
+        return result ? R.success("新增成功") : R.fail("新增失败");
+    }
+
+    @Override
+    public R<String> deleteById(Long id) {
+        log.info("CommonPushTaskUserServiceImpl.deleteById, id={}", id);
+        boolean result = this.removeById(id);
+        return result ? R.success("删除成功") : R.fail("删除失败");
+    }
+
+    @Override
+    public R<String> update(CommonPushTaskUser taskUser) {
+        log.info("CommonPushTaskUserServiceImpl.update, param={}", taskUser);
+        if (taskUser.getId() == null) {
+            return R.fail("id不能为空");
+        }
+        boolean result = this.updateById(taskUser);
+        return result ? R.success("更新成功") : R.fail("更新失败");
+    }
+
+    @Override
+    public R<CommonPushTaskUser> getInfoById(Long id) {
+        log.info("CommonPushTaskUserServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<CommonPushTaskUser>> list(Integer pageNum, Integer pageSize, Long pushTaskId, Long userId, String deviceId) {
+        log.info("CommonPushTaskUserServiceImpl.list, pageNum={}, pageSize={}, pushTaskId={}, userId={}, deviceId={}",
+                pageNum, pageSize, pushTaskId, userId, deviceId);
+        Page<CommonPushTaskUser> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<CommonPushTaskUser> wrapper = new LambdaQueryWrapper<>();
+        if (pushTaskId != null) {
+            wrapper.eq(CommonPushTaskUser::getPushTaskId, pushTaskId);
+        }
+        if (userId != null) {
+            wrapper.eq(CommonPushTaskUser::getUserId, userId);
+        }
+        if (StringUtils.isNotBlank(deviceId)) {
+            wrapper.eq(CommonPushTaskUser::getDeviceId, deviceId);
+        }
+        wrapper.orderByDesc(CommonPushTaskUser::getUpdatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+
+    @Override
+    public R<String> callbackFromVendor(String callBackParameter, String channelStatus,
+                                        String registrationIds, String eventType) {
+        log.info("CommonPushTaskUserServiceImpl.callbackFromVendor, callBackParameter={}, channelStatus={}, registrationIds={}, eventType={}",
+                callBackParameter, channelStatus, registrationIds, eventType);
+        if (StringUtils.isBlank(callBackParameter)) {
+            return R.fail("callBackParameter不能为空");
+        }
+        CommonPushTaskUserCallbackDto callbackDto = new CommonPushTaskUserCallbackDto();
+        try {
+            JSONObject paramJson = JSONObject.parseObject(callBackParameter);
+            if (paramJson != null) {
+                fillCallbackDtoFromParamJson(callbackDto, paramJson);
+            }
+        } catch (Exception e) {
+            log.warn("callBackParameter 非 JSON,按原始字符串处理: {}", callBackParameter);
+        }
+        if (StringUtils.isNotBlank(registrationIds) && StringUtils.isBlank(callbackDto.getDeviceId())) {
+            callbackDto.setDeviceId(extractFirstRegistrationId(registrationIds));
+        }
+        callbackDto.setChannelStatus(channelStatus);
+        if (StringUtils.isBlank(callbackDto.getChannelCode())) {
+            callbackDto.setChannelCode("oppo");
+        }
+        if ("0".equals(channelStatus) || "success".equalsIgnoreCase(channelStatus)
+                || "push_arrive".equalsIgnoreCase(eventType)) {
+            callbackDto.setEventType("delivered");
+        }
+        return callback(callbackDto);
+    }
+
+    private void fillCallbackDtoFromParamJson(CommonPushTaskUserCallbackDto callbackDto, JSONObject paramJson) {
+        callbackDto.setTaskNo(StringUtils.defaultIfBlank(paramJson.getString("taskNo"), paramJson.getString("t")));
+        Long pushTaskId = paramJson.getLong("pushTaskId");
+        if (pushTaskId == null) {
+            pushTaskId = paramJson.getLong("p");
+        }
+        callbackDto.setPushTaskId(pushTaskId);
+        Long userId = paramJson.getLong("userId");
+        if (userId == null) {
+            userId = paramJson.getLong("u");
+        }
+        callbackDto.setUserId(userId);
+        callbackDto.setDeviceId(StringUtils.defaultIfBlank(paramJson.getString("deviceId"), paramJson.getString("d")));
+        callbackDto.setChannelCode(paramJson.getString("channelCode"));
+    }
+
+    private String extractFirstRegistrationId(String registrationIds) {
+        if (StringUtils.isBlank(registrationIds)) {
+            return null;
+        }
+        String[] parts = registrationIds.split(",");
+        for (String part : parts) {
+            if (StringUtils.isNotBlank(part)) {
+                return part.trim();
+            }
+        }
+        return registrationIds.trim();
+    }
+
+    @Override
+    public R<String> callback(CommonPushTaskUserCallbackDto callbackDto) {
+        log.info("CommonPushTaskUserServiceImpl.callback, param={}", callbackDto);
+        if (callbackDto == null) {
+            return R.fail("回调参数不能为空");
+        }
+        normalizeChannelPayload(callbackDto);
+        applyEventType(callbackDto);
+
+        if (callbackDto.getStatus() == null && callbackDto.getShowInfo() == null && callbackDto.getUserAdd() == null) {
+            return R.fail("至少提供一个状态字段或 eventType");
+        }
+
+        CommonPushTaskUser record = findCallbackRecord(callbackDto);
+        if (record == null) {
+            return R.fail("未找到推送记录");
+        }
+
+        LambdaUpdateWrapper<CommonPushTaskUser> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.eq(CommonPushTaskUser::getId, record.getId());
+        boolean hasUpdate = false;
+        if (callbackDto.getStatus() != null) {
+            updateWrapper.set(CommonPushTaskUser::getStatus, callbackDto.getStatus());
+            hasUpdate = true;
+        }
+        if (callbackDto.getShowInfo() != null) {
+            updateWrapper.set(CommonPushTaskUser::getShowInfo, callbackDto.getShowInfo());
+            hasUpdate = true;
+        }
+        if (callbackDto.getUserAdd() != null) {
+            updateWrapper.set(CommonPushTaskUser::getUserAdd, callbackDto.getUserAdd());
+            hasUpdate = true;
+        }
+        if (!hasUpdate) {
+            return R.fail("无可更新字段");
+        }
+        boolean result = this.update(updateWrapper);
+        if (result && callbackDto.getStatus() != null && callbackDto.getStatus() == 1) {
+            updatePushTaskDelivered(record.getPushTaskId());
+        }
+        return result ? R.success("回调处理成功") : R.fail("回调处理失败");
+    }
+
+    private void updatePushTaskDelivered(Long pushTaskId) {
+        if (pushTaskId == null) {
+            return;
+        }
+        CommonPushTask task = new CommonPushTask();
+        task.setId(pushTaskId);
+        task.setStatus(CommonPushTaskStatus.DELIVERED);
+        commonPushTaskMapper.updateById(task);
+    }
+
+    private CommonPushTaskUser findCallbackRecord(CommonPushTaskUserCallbackDto callbackDto) {
+        if (callbackDto.getId() != null) {
+            return this.getById(callbackDto.getId());
+        }
+        Long pushTaskId = callbackDto.getPushTaskId();
+        if (pushTaskId == null && StringUtils.isNotBlank(callbackDto.getTaskNo())) {
+            CommonPushTask task = commonPushTaskMapper.selectOne(
+                    new LambdaQueryWrapper<CommonPushTask>()
+                            .eq(CommonPushTask::getTaskNo, callbackDto.getTaskNo().trim())
+                            .last("LIMIT 1"));
+            if (task != null) {
+                pushTaskId = task.getId();
+            }
+        }
+        if (pushTaskId == null) {
+            return null;
+        }
+        LambdaQueryWrapper<CommonPushTaskUser> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(CommonPushTaskUser::getPushTaskId, pushTaskId);
+        if (StringUtils.isNotBlank(callbackDto.getDeviceId())) {
+            wrapper.eq(CommonPushTaskUser::getDeviceId, callbackDto.getDeviceId().trim());
+        } else if (callbackDto.getUserId() != null) {
+            wrapper.eq(CommonPushTaskUser::getUserId, callbackDto.getUserId());
+        } else {
+            return null;
+        }
+        wrapper.orderByDesc(CommonPushTaskUser::getCreatedTime);
+        wrapper.last("LIMIT 1");
+        return this.getOne(wrapper);
+    }
+
+    private void applyEventType(CommonPushTaskUserCallbackDto callbackDto) {
+        if (StringUtils.isBlank(callbackDto.getEventType())) {
+            return;
+        }
+        String eventType = callbackDto.getEventType().trim().toLowerCase();
+        switch (eventType) {
+            case "sent":
+            case "send":
+                if (callbackDto.getStatus() == null) {
+                    callbackDto.setStatus(0);
+                }
+                break;
+            case "delivered":
+            case "delivery":
+            case "arrive":
+            case "receive":
+                if (callbackDto.getStatus() == null) {
+                    callbackDto.setStatus(1);
+                }
+                break;
+            case "show":
+            case "display":
+            case "impression":
+                if (callbackDto.getShowInfo() == null) {
+                    callbackDto.setShowInfo(1);
+                }
+                break;
+            case "click":
+            case "open":
+            case "tap":
+                if (callbackDto.getShowInfo() == null) {
+                    callbackDto.setShowInfo(1);
+                }
+                if (callbackDto.getUserAdd() == null) {
+                    callbackDto.setUserAdd(1);
+                }
+                break;
+            default:
+                log.warn("未识别的事件类型: {}", eventType);
+        }
+    }
+
+    /**
+     * 将各渠道原始状态码映射为统一状态字段。
+     */
+    private void normalizeChannelPayload(CommonPushTaskUserCallbackDto callbackDto) {
+        if (StringUtils.isBlank(callbackDto.getChannelCode()) || StringUtils.isBlank(callbackDto.getChannelStatus())) {
+            return;
+        }
+        String channelCode = callbackDto.getChannelCode().trim().toLowerCase();
+        String channelStatus = callbackDto.getChannelStatus().trim().toLowerCase();
+        switch (channelCode) {
+            case "vivo":
+            case "xiaomi":
+            case "oppo":
+            case "samsung":
+                if ("0".equals(channelStatus) || "success".equals(channelStatus) || "ok".equals(channelStatus)
+                        || "1000".equals(channelStatus)) {
+                    if (callbackDto.getStatus() == null) {
+                        callbackDto.setStatus(1);
+                    }
+                }
+                break;
+            case "huawei":
+            case "honor":
+                if ("delivered".equals(channelStatus) || "success".equals(channelStatus)) {
+                    if (callbackDto.getStatus() == null) {
+                        callbackDto.setStatus(1);
+                    }
+                }
+                break;
+            case "apns":
+                if ("delivered".equals(channelStatus) || "200".equals(channelStatus)) {
+                    if (callbackDto.getStatus() == null) {
+                        callbackDto.setStatus(1);
+                    }
+                }
+                break;
+            default:
+                break;
+        }
+    }
+}

+ 26 - 26
alien-store/src/main/java/shop/alien/store/service/impl/StoreCommentAppealServiceImpl.java

@@ -39,8 +39,8 @@ import shop.alien.util.type.LifeNoticeUtil;
 
 import java.net.URLEncoder;
 import java.text.SimpleDateFormat;
-import java.time.Duration;
-import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.*;
 import java.util.stream.Collectors;
@@ -734,51 +734,51 @@ public class StoreCommentAppealServiceImpl extends ServiceImpl<StoreCommentAppea
         int passedCount = passedAppealList.size();
         // 列表已按 updated_time 降序,第一条即为最新通过记录
         StoreCommentAppeal latestPassedAppeal = passedAppealList.isEmpty() ? null : passedAppealList.get(0);
-        long countdownMillis = 0l;
+        String countdown = "0天";
+        long remainingDays = 0L;
         if (passedCount > 3) {
-            countdownMillis = calculateAppealPassCooldownCountdown(latestPassedAppeal);
+            remainingDays = resolveAppealPassCooldownRemainingDays(latestPassedAppeal);
+            countdown = calculateAppealPassCooldownCountdown(remainingDays);
         }
 
-        boolean canRate = passedCount <= 3 || countdownMillis <= 0;
+        boolean canRate = passedCount <= 3 || remainingDays <= 0;
         resultMap.put("canRate", canRate);
         resultMap.put("passedCount", passedCount);
         resultMap.put("latestPassedAppeal", latestPassedAppeal);
-        resultMap.put("countdown", formatCountdownToDayHour(countdownMillis));
+        resultMap.put("countdown", countdown);
 
         log.info("StoreCommentAppealServiceImpl.canRate result, userId={}, canRate={}, passedCount={}, countdown={}",
-                userId, canRate, passedCount, formatCountdownToDayHour(countdownMillis));
+                userId, canRate, passedCount, countdown);
         return resultMap;
     }
 
     /**
-     * 将毫秒倒计时转换为「X天X小时」格式
+     * 计算申诉通过后的10天冷却倒计时(仅天数)
      */
-    private String formatCountdownToDayHour(long countdownMillis) {
-        if (countdownMillis <= 0) {
-            return "0天0小时";
-        }
-        long totalHours = countdownMillis / (1000 * 60 * 60);
-        long days = totalHours / 24;
-        long hours = totalHours % 24;
-        return days + "天" + hours + "小时";
+    private String calculateAppealPassCooldownCountdown(long remainingDays) {
+        String countdown = remainingDays + "天";
+        log.debug("calculateAppealPassCooldownCountdown, remainingDays={}, countdown={}", remainingDays, countdown);
+        return countdown;
     }
 
     /**
-     * 计算申诉通过后的10天冷却倒计时(毫秒
-     * 以申诉更新时间(通过时间)为起点,10天内返回剩余毫秒数,否则返回0
+     * 解析申诉通过后10天冷却剩余天数(按自然日计算,不含小时)
+     * 以申诉更新日期(通过日期)为起点,满10个自然日后冷却结束
      */
-    private long calculateAppealPassCooldownCountdown(StoreCommentAppeal latestPassedAppeal) {
+    private long resolveAppealPassCooldownRemainingDays(StoreCommentAppeal latestPassedAppeal) {
         if (latestPassedAppeal == null || latestPassedAppeal.getUpdatedTime() == null) {
             return 0L;
         }
-        LocalDateTime passTime = latestPassedAppeal.getUpdatedTime().toInstant()
-                .atZone(ZoneId.systemDefault()).toLocalDateTime();
-        LocalDateTime cooldownEndTime = passTime.plusDays(10);
-        LocalDateTime now = LocalDateTime.now();
-        if (now.isBefore(cooldownEndTime)) {
-            return Duration.between(now, cooldownEndTime).toMillis();
+        LocalDate passDate = latestPassedAppeal.getUpdatedTime().toInstant()
+                .atZone(ZoneId.systemDefault()).toLocalDate();
+        LocalDate cooldownEndDate = passDate.plusDays(10);
+        LocalDate today = LocalDate.now();
+        if (!today.isBefore(cooldownEndDate)) {
+            log.debug("resolveAppealPassCooldownRemainingDays: 冷却已结束, passDate={}, cooldownEndDate={}",
+                    passDate, cooldownEndDate);
+            return 0L;
         }
-        return 0L;
+        return ChronoUnit.DAYS.between(today, cooldownEndDate);
     }
 
     /**

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

+ 387 - 0
docs/devops/dev/Introduction.md

@@ -0,0 +1,387 @@
+# 开发环境(deve)运维说明
+
+本文档描述与 **测试环境(sit/test)** 并行运行的 **开发环境** 的设计、部署与日常运维。配置脚本位于仓库 `docs/devops/dev/`。
+
+---
+
+## 1. 环境与目标
+
+| 环境 | Git 分支 | Spring Profile | 宿主机 Java 目录 | 说明 |
+|------|----------|----------------|------------------|------|
+| 测试 sit | `sit` | `test` | `/docker/java` | 现有联调 / 测试 |
+| **开发 deve** | **`deve`** | **`dev`** | **`/deve/java`** | 本环境 |
+
+开发环境目标:
+
+- 与 test **共用中间件**(MySQL、Nacos、RocketMQ、xxl-job-admin、Jenkins),节省资源
+- Java 服务、Redis、业务库、Nacos 命名空间 **隔离**,避免 dev 污染 test
+- Jenkins **按需构建**微服务,默认 `gateway + store + second`
+- 开发者可用 IDEA 直连 dev 中间件,或在服务器跑 `java-dev` 容器联调
+
+---
+
+## 2. 架构概览
+
+```mermaid
+flowchart TB
+  subgraph host["测试机 120.26.186.130"]
+    subgraph mw["/docker/middleware 共用"]
+      MySQL[(MySQL 30001)]
+      Nacos[Nacos 8848]
+      RMQ[RocketMQ]
+      XXL[xxl-job-admin 30019]
+      RedisTest[redis 30002]
+      Jenkins[Jenkins 30003]
+      Nginx[Nginx 80/443]
+    end
+
+    subgraph mwdev["/docker/middleware-dev 增补"]
+      RedisDev[redis-dev 20022]
+    end
+
+    subgraph test["/docker/java 测试"]
+      GWt[gateway :8000 profile=test]
+    end
+
+    subgraph dev["/deve/java 开发"]
+      GWd[gateway-dev :28000 profile=dev]
+    end
+  end
+
+  GWt --> Nacos
+  GWd --> Nacos
+  GWt --> RedisTest
+  GWd --> RedisDev
+  GWt --> MySQL
+  GWd --> MySQL
+  Jenkins --> dev
+  Nginx --> GWt
+  Nginx --> GWd
+```
+
+---
+
+## 3. 隔离矩阵
+
+| 资源 | 测试 | 开发 | 隔离方式 |
+|------|------|------|----------|
+| MySQL 实例 | 共用 `mysql:3306` | 共用 | **不同 database**(dev 库) |
+| Nacos | namespace `acd615de-…` | **public / dev 命名空间** | `bootstrap-test.yml` vs `bootstrap-dev.yml` |
+| Redis | `redis-6.0.8` `30002` | **`redis-dev` `20022`** | 独立实例 + 独立数据目录 |
+| RocketMQ | 共用 | 共用 | consumer group / topic 前缀在 Nacos 区分 |
+| xxl-job-admin | 共用 `30019` | 共用 | **执行器 appname + 回调端口** 区分 |
+| Java 容器 | `gateway`… | `gateway-dev`… | 独立 compose、端口 `28xxx` |
+| 日志 | `/docker/java/logs` | `/deve/java/logs` | 独立目录 |
+
+---
+
+## 4. 服务器目录与仓库对应
+
+### 4.1 宿主机路径
+
+```text
+/docker/middleware/              # 测试中间件(已有)
+/docker/middleware-dev/          # 开发增补中间件(redis-dev)
+/docker/java/                    # 测试 Java 服务
+/deve/java/                      # 开发 Java 服务(compose + jar + logs)
+```
+
+首次部署建议从仓库复制:
+
+```bash
+# 开发 Java
+mkdir -p /deve/java
+cp docs/devops/dev/java/docker-compose.yml /deve/java/
+mkdir -p /deve/java/gateway /deve/java/store /deve/java/second \
+         /deve/java/store-platform /deve/java/lawyer /deve/java/job /deve/java/dining \
+         /deve/java/logs/gateway /deve/java/logs/store ...
+
+# 开发 Redis
+mkdir -p /docker/middleware-dev
+cp -r docs/devops/dev/middleware/* /docker/middleware-dev/
+```
+
+### 4.2 仓库文件索引
+
+| 路径 | 说明 |
+|------|------|
+| `docs/devops/dev/Introduction.md` | 本文档 |
+| `docs/devops/dev/java/docker-compose.yml` | 7 个 Java 微服务 compose |
+| `docs/devops/dev/java/Jenkinsfile` | Jenkins 流水线(Pipeline from SCM) |
+| `docs/devops/dev/middleware/docker-compose.yml` | redis-dev |
+| `docs/devops/dev/middleware/redis-dev/conf/redis.conf` | dev Redis 配置 |
+
+---
+
+## 5. 端口一览
+
+### 5.1 Java 服务(宿主机 → 容器)
+
+| 服务 | 容器名 | compose 服务名 | 宿主机端口 | 容器端口 | Profile |
+|------|--------|----------------|------------|----------|---------|
+| gateway | `gateway-dev` | `gateway` | **28000** | 8000 | dev |
+| store | `store-dev` | `store` | **28004** | 30004 | dev |
+| second | `second-dev` | `second` | **28005** | 30005 | dev |
+| store-platform | `store-platform-dev` | `store-platform` | **28006** | 30006 | dev |
+| lawyer | `lawyer-dev` | `lawyer` | **28007** | 30007 | dev |
+| job | `job-dev` | `job` | **28008** / **28018** | 30008 / 9999 | dev |
+| dining | `dining-dev` | `dining` | **28014** | 30014 | dev |
+
+对比测试环境:gateway `8000`,store `30004`,job 回调 `30018`。
+
+### 5.2 中间件(开发相关)
+
+| 组件 | 宿主机端口 | 容器内访问(app-network) |
+|------|------------|---------------------------|
+| redis-dev | **20022** | `redis-dev:6379` |
+| MySQL(共用) | 30001 | `mysql:3306` |
+| Nacos(共用) | 8848 | `nacos-2.5.2:8848` |
+| xxl-job-admin(共用) | 30019 | `xxl-job-admin:8080` |
+
+---
+
+## 6. 部署步骤
+
+### 6.1 前置条件
+
+```bash
+docker network inspect app-network   # 须已存在(middleware 创建)
+```
+
+### 6.2 启动 redis-dev
+
+```bash
+cd /docker/middleware-dev
+docker compose up -d
+docker exec redis-dev redis-cli -a Alien123456 ping
+```
+
+### 6.3 启动 Java 服务
+
+日常最小集(约 2~2.5GB 额外内存):
+
+```bash
+cd /deve/java
+docker compose up -d gateway store second
+```
+
+全量:
+
+```bash
+cd /deve/java
+docker compose up -d
+```
+
+### 6.4 Jenkins 容器挂载(必配)
+
+在 `/docker/middleware/docker-compose.yml` 的 `jenkins` 服务中增加:
+
+```yaml
+volumes:
+  - /deve/java:/deve/java          # dev 部署目录
+  # 已有:../java:/app_deploy、docker.sock 等
+```
+
+---
+
+## 7. Nacos 配置(dev 命名空间)
+
+`spring.profiles.active=dev` 读取 `bootstrap-dev.yml`(默认 **public** 命名空间,与 test 的 `acd615de-…` 分离)。
+
+在 dev 命名空间 `common.yml`(及 `alien-job` 等)中确认:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://mysql:3306/<dev库名>?...
+  redis:
+    host: redis-dev
+    port: 6379
+    password: Alien123456
+
+xxl:
+  job:
+    admin:
+      addresses: http://xxl-job-admin:8080/xxl-job-admin
+    accessToken: default_token
+    executor:
+      appname: alien-job-dev          # 与 test 的 alien-job 区分
+      ip: 120.26.186.130
+      port: 28018                     # 宿主机映射端口(非容器内 9999)
+      logpath: /app/logs
+```
+
+RocketMQ:为 dev 配置独立 `consumer.group` 前缀,避免与 test 抢消费。
+
+---
+
+## 8. xxl-job 共用 Admin 的隔离
+
+共用 **一个** `xxl-job-admin`(`30019`),通过 **执行器** 隔离:
+
+| 项 | test (`job`) | dev (`job-dev`) |
+|----|--------------|-----------------|
+| executor.appname | `alien-job` | **`alien-job-dev`** |
+| 回调地址 | `120.26.186.130:30018` | **`120.26.186.130:28018`** |
+| 业务库 | test 库 | dev 库(profile=dev) |
+
+控制台操作:
+
+1. 「执行器管理」中应看到两组:`alien-job` 与 `alien-job-dev` 均 **在线**
+2. dev 定时任务绑定 **`alien-job-dev`** 执行器;test 任务绑定 `alien-job`
+3. dev 任务默认可 **停止**,联调时再启用
+
+验证:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:28018/
+curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:30018/
+```
+
+---
+
+## 9. Jenkins 流水线
+
+### 9.1 Job 配置
+
+| 项 | 值 |
+|----|-----|
+| 类型 | Pipeline script from SCM |
+| 仓库 | `http://8.152.195.41:3000/alien/alien_cloud` |
+| 默认分支 | `*/deve` |
+| Script Path | **`docs/devops/dev/java/Jenkinsfile`** |
+
+保存后执行一次构建以注册参数。
+
+### 9.2 构建参数
+
+| 参数 | 默认 | 说明 |
+|------|------|------|
+| `GIT_BRANCH` | `deve` | 可改分支 |
+| `STOP_ALL` | `false` | 为 true 时先 `docker compose stop` 全部 dev 服务 |
+| `DEPLOY_gateway` | **true** | |
+| `DEPLOY_store` | **true** | |
+| `DEPLOY_second` | **true** | |
+| `DEPLOY_store_platform` | false | |
+| `DEPLOY_lawyer` | false | |
+| `DEPLOY_job` | false | |
+| `DEPLOY_dining` | false | |
+
+规则:
+
+- 至少勾选一个微服务
+- `STOP_ALL=true` 时 **必须勾选 gateway**
+- 流水线:`Checkout → [Stop All] → Maven(-pl 勾选模块 -am) → 拷贝 jar 到 /deve/java → compose up -d + restart`
+
+### 9.3 与 sit 任务对比
+
+| sit | deve |
+|-----|------|
+| 脚本写在 Job 内 | SCM `Jenkinsfile` |
+| 分支 `sit` | 参数 `GIT_BRANCH`,默认 `deve` |
+| 全量构建部署 | 按需模块 |
+| `/app_deploy` + `docker restart` | `/deve/java` + `docker compose` |
+
+---
+
+## 10. 内存与资源建议
+
+基于测试机 `docker stats`(7 个 test Java 合计约 5.6GB RSS,宿主机约 10GB 可用):
+
+| 策略 | 服务 | 大约额外内存 |
+|------|------|--------------|
+| **日常推荐** | gateway + store + second | ~2~2.5 GB |
+| 全链路 | 7 个 Java | ~4.5~5.5 GB |
+
+dev compose 默认:`mem_limit` 800M(store 700M),`-Xmx384m`。`store-dev` 为最重模块,若 OOM 可适当提高 `mem_limit` 或 `-Xmx`。
+
+---
+
+## 11. 日常运维命令
+
+```bash
+# 查看 dev 容器状态
+cd /deve/java && docker compose ps
+
+# 查看内存
+docker stats --no-stream gateway-dev store-dev job-dev --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"
+
+# 重启单个服务
+cd /deve/java && docker compose restart store
+
+# 停止全部 dev Java(不影响 test)
+cd /deve/java && docker compose stop
+
+# 日志
+tail -f /deve/java/logs/store/alien-store/*.log
+docker logs -f store-dev --tail 200
+
+# 健康检查
+curl -s http://127.0.0.1:28000/actuator/health
+```
+
+### 日志备份示例
+
+```bash
+tar -czf /tmp/alien-store-logs-$(date +%Y%m%d-%H%M%S).tar.gz \
+  -C /deve/java/logs/store alien-store
+```
+
+---
+
+## 12. 本地 IDEA 直连
+
+不启动服务器 Java 容器时,可本地起单个服务并连 dev 中间件:
+
+| 配置项 | 值 |
+|--------|-----|
+| `spring.profiles.active` | `dev` |
+| MySQL | `120.26.186.130:30001`,dev 库 |
+| Redis | `120.26.186.130:20022`,密码 `Alien123456` |
+| Nacos | `120.26.186.130:8848`,dev 命名空间 |
+
+**勿**使用 test 的 Nacos namespace,避免注册到 test 服务发现。
+
+---
+
+## 13. Nginx(待配置)
+
+在现有 `nginx-1.28` 的 `conf.d` 中增加 dev 入口(示例):
+
+```nginx
+upstream dev_gateway { server 127.0.0.1:28000; }
+
+server {
+    listen 443 ssl;
+    server_name dev.ailien.shop;
+    location / {
+        proxy_pass http://dev_gateway;
+    }
+}
+```
+
+静态资源建议独立目录,例如 `/docker/middleware/nginx/html-dev`。
+
+---
+
+## 14. 常见问题
+
+| 现象 | 可能原因 | 处理 |
+|------|----------|------|
+| dev 请求打到 test 服务 | Profile 仍为 `test` 或 Nacos namespace 错误 | 确认 `-Dspring.profiles.active=dev` |
+| Redis 认证失败 | Nacos 未配 password | `common.yml` 补 `Alien123456` |
+| xxl-job 回调失败 | executor.port 写成 9999 | 宿主机侧应为 **28018** |
+| Jenkins 部署 jar 失败 | 未挂载 `/deve/java` | 检查 jenkins volumes |
+| 新 jar 不生效 | 仅 `up -d` 未 restart | 流水线已 `up -d` + `restart`;手工同理 |
+| `STOP_ALL` 后网关不通 | 未勾选 gateway | 勾选 `DEPLOY_gateway` |
+| 构建参数无复选框 | Job 有旧的手动参数冲突 | 删除 Job 内重复参数,重新保存 Job |
+
+---
+
+## 15. 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-06 | 初版:dev Java compose、redis-dev、Jenkinsfile、本文档 |
+
+配置变更请同步更新仓库 `docs/devops/dev/` 下对应文件,再部署到服务器。

+ 244 - 0
docs/devops/dev/java/Jenkinsfile

@@ -0,0 +1,244 @@
+/**
+ * 开发环境(deve):Checkout -> Maven(按需模块)-> 拷贝 jar -> docker compose 启停
+ *
+ * Jenkins Job 配置:
+ *   - Pipeline script from SCM
+ *   - Repository: http://8.152.195.41:3000/alien/alien_cloud
+ *   - Script Path: docs/devops/dev/java/Jenkinsfile
+ *   - 分支默认 deve(构建参数 GIT_BRANCH 可覆盖)
+ *
+ * 宿主机目录:/deve/java(docker-compose.yml 与 gateway/store/... 子目录)
+ * Jenkins 容器须能访问该路径(建议挂载 /deve/java:/deve/java)及 docker.sock
+ *
+ * 容器名:gateway-dev、store-dev…(compose 内 services 键仍为 gateway、store…)
+ */
+
+def getServiceDefinitions() {
+    return [
+            [module: 'alien-gateway',        dir: 'gateway',        composeService: 'gateway',        container: 'gateway-dev',        withLib: false],
+            [module: 'alien-store',          dir: 'store',          composeService: 'store',          container: 'store-dev',          withLib: true],
+            [module: 'alien-second',         dir: 'second',         composeService: 'second',         container: 'second-dev',         withLib: false],
+            [module: 'alien-store-platform', dir: 'store-platform', composeService: 'store-platform', container: 'store-platform-dev', withLib: false],
+            [module: 'alien-lawyer',         dir: 'lawyer',         composeService: 'lawyer',         container: 'lawyer-dev',         withLib: false],
+            [module: 'alien-job',            dir: 'job',            composeService: 'job',            container: 'job-dev',            withLib: false],
+            [module: 'alien-dining',         dir: 'dining',         composeService: 'dining',         container: 'dining-dev',         withLib: true],
+    ]
+}
+
+def deployParamName(String composeService) {
+    return 'DEPLOY_' + composeService.replace('-', '_')
+}
+
+def collectSelectedServices(Map buildParams) {
+    def selected = []
+    getServiceDefinitions().each { svc ->
+        def paramName = deployParamName(svc.composeService)
+        if (buildParams[paramName]) {
+            selected.add(svc)
+        }
+    }
+    return selected
+}
+
+def deployOneService(String workspace, String deployRoot, Map svc) {
+    def sourceJar = "${workspace}/${svc.module}/target/${svc.module}-1.0.0.jar"
+    def sourceLib = "${workspace}/${svc.module}/target/lib"
+    def targetDir = "${deployRoot}/${svc.dir}"
+    sh """
+        set -e
+        echo ">>> Deploy module: ${svc.module} -> ${targetDir}"
+        if [ ! -f "${sourceJar}" ]; then
+            echo ">>> [${svc.dir}] jar missing, skip copy"
+            exit 0
+        fi
+        mkdir -p "${targetDir}"
+        if [ "${svc.withLib}" = "true" ] && [ -d "${sourceLib}" ]; then
+            rm -rf "${targetDir}/lib"
+            cp -rf "${sourceLib}" "${targetDir}/lib"
+        fi
+        cp -f "${sourceJar}" "${targetDir}/"
+        echo ">>> [${svc.dir}] jar copied"
+    """
+}
+
+def composeStop(String deployRoot, List composeServices) {
+    if (composeServices == null || composeServices.isEmpty()) {
+        return
+    }
+    def svcList = composeServices.join(' ')
+    sh """
+        set -e
+        cd "${deployRoot}"
+        test -f docker-compose.yml
+        echo ">>> docker compose stop ${svcList}"
+        docker compose stop ${svcList}
+    """
+}
+
+def startOrRestartServices(String deployRoot, List composeServices) {
+    if (composeServices == null || composeServices.isEmpty()) {
+        return
+    }
+    def svcList = composeServices.join(' ')
+    sh """
+        set -e
+        cd "${deployRoot}"
+        test -f docker-compose.yml
+        echo ">>> docker compose up -d ${svcList}"
+        docker compose up -d ${svcList}
+        echo ">>> docker compose restart ${svcList}"
+        docker compose restart ${svcList}
+    """
+}
+
+pipeline {
+    agent any
+
+    options {
+        buildDiscarder(logRotator(numToKeepStr: '2', artifactNumToKeepStr: '2'))
+        disableConcurrentBuilds()
+        timestamps()
+        timeout(time: 60, unit: 'MINUTES')
+    }
+
+    parameters {
+        string(
+                name: 'GIT_BRANCH',
+                defaultValue: 'deve',
+                trim: true,
+                description: '构建分支,默认 deve,可在「Build with Parameters」中修改'
+        )
+        booleanParam(
+                name: 'STOP_ALL',
+                defaultValue: false,
+                description: '为 true 时:先停止全部 dev 微服务,再按下方勾选部署;此时 gateway 必须勾选'
+        )
+        // 多选复选框(Declarative 须逐项声明,不可用 each 动态生成)
+        booleanParam(name: 'DEPLOY_gateway', defaultValue: true, description: 'alien-gateway -> gateway-dev')
+        booleanParam(name: 'DEPLOY_store', defaultValue: true, description: 'alien-store -> store-dev')
+        booleanParam(name: 'DEPLOY_second', defaultValue: true, description: 'alien-second -> second-dev')
+        booleanParam(name: 'DEPLOY_store_platform', defaultValue: false, description: 'alien-store-platform -> store-platform-dev')
+        booleanParam(name: 'DEPLOY_lawyer', defaultValue: false, description: 'alien-lawyer -> lawyer-dev')
+        booleanParam(name: 'DEPLOY_job', defaultValue: false, description: 'alien-job -> job-dev')
+        booleanParam(name: 'DEPLOY_dining', defaultValue: false, description: 'alien-dining -> dining-dev')
+    }
+
+    environment {
+        MAVEN_HOME = tool '3.6.3'
+        PATH = "${MAVEN_HOME}/bin:${env.PATH}"
+        GIT_URL = 'http://8.152.195.41:3000/alien/alien_cloud'
+        GIT_CREDENTIALS = '5e058e17-8089-45e0-a802-596d91758b4d'
+        DEPLOY_ROOT = '/deve/java'
+        MAVEN_LOCAL_REPO = '/var/jenkins_home/.m2/repository'
+    }
+
+    stages {
+        stage('Validate') {
+            steps {
+                script {
+                    def selected = collectSelectedServices(params)
+                    if (selected.isEmpty()) {
+                        error('请至少勾选一个微服务')
+                    }
+                    if (params.STOP_ALL && !params.DEPLOY_gateway) {
+                        error('STOP_ALL=true 时 gateway 必须勾选')
+                    }
+                    env.SELECTED_COMPOSE_SERVICES = selected.collect { it.composeService }.join(' ')
+                    env.SELECTED_MAVEN_MODULES = selected.collect { it.module }.join(',')
+                    echo ">>> Branch: ${params.GIT_BRANCH}"
+                    echo ">>> STOP_ALL: ${params.STOP_ALL}"
+                    echo ">>> Selected compose services: ${env.SELECTED_COMPOSE_SERVICES}"
+                }
+            }
+        }
+
+        stage('Checkout') {
+            steps {
+                script {
+                    def branch = (params.GIT_BRANCH ?: 'deve').trim()
+                    if (!branch) {
+                        error('GIT_BRANCH is required')
+                    }
+                    env.GIT_BRANCH = branch
+                    echo ">>> Checkout branch: ${env.GIT_BRANCH}"
+                    git branch: "${env.GIT_BRANCH}",
+                            credentialsId: "${env.GIT_CREDENTIALS}",
+                            url: "${env.GIT_URL}"
+                    sh """
+                        set -e
+                        git fetch origin
+                        git reset --hard origin/${env.GIT_BRANCH}
+                        git log -1 --oneline
+                    """
+                }
+            }
+        }
+
+        stage('Stop All Dev Services') {
+            when {
+                expression { return params.STOP_ALL }
+            }
+            steps {
+                script {
+                    def allServices = getServiceDefinitions().collect { it.composeService }
+                    composeStop(env.DEPLOY_ROOT, allServices)
+                }
+            }
+        }
+
+        stage('Maven Build') {
+            steps {
+                sh """
+                    set -e
+                    cat > settings.xml <<'EOF'
+<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
+  <mirrors>
+    <mirror>
+      <id>aliyunmaven</id>
+      <mirrorOf>*</mirrorOf>
+      <name>Aliyun Maven</name>
+      <url>https://maven.aliyun.com/repository/public</url>
+    </mirror>
+  </mirrors>
+</settings>
+EOF
+                    echo ">>> Maven modules: ${SELECTED_MAVEN_MODULES}"
+                    mvn clean package -DskipTests -s settings.xml -pl ${SELECTED_MAVEN_MODULES} -am
+                """
+            }
+        }
+
+        stage('Deploy Services') {
+            steps {
+                script {
+                    def selected = collectSelectedServices(params)
+                    selected.each { svc ->
+                        deployOneService(env.WORKSPACE, env.DEPLOY_ROOT, svc)
+                    }
+                }
+            }
+        }
+
+        stage('Start Dev Services') {
+            steps {
+                script {
+                    def selected = collectSelectedServices(params)
+                    def composeServices = selected.collect { it.composeService }
+                    startOrRestartServices(env.DEPLOY_ROOT, composeServices)
+                }
+            }
+        }
+    }
+
+    post {
+        always {
+            sh 'rm -f settings.xml || true'
+            echo '>>> 开发环境构建结束'
+        }
+        failure {
+            echo '>>> 构建失败,已部署的 jar 不会自动回滚,请检查日志'
+        }
+    }
+}

+ 133 - 0
docs/devops/dev/java/docker-compose.yml

@@ -0,0 +1,133 @@
+# 1. 基础环境变量(所有服务通用)
+x-java-env: &java-env
+  TZ: Asia/Shanghai
+  JASYPT_ENCRYPTOR_PASSWORD: alien_salt
+  LOGGING_PATH: /app/logs
+# 2. 代理环境变量(在基础版之上增加代理)
+x-proxy-env: &proxy-env
+  <<: *java-env
+
+# 3. 容器基础配置模板
+x-java-common: &java-common
+  restart: unless-stopped
+  working_dir: /app
+  networks:
+    - app-network
+  mem_limit: 800M
+  mem_reservation: 256M
+
+services:
+  gateway:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: gateway-dev
+    environment:
+      <<: *java-env # 仅引用基础环境变量
+    volumes:
+      - ./gateway:/app
+      - ./logs/gateway:/app/logs
+    ports:
+      - "28000:8000"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -jar /app/alien-gateway-1.0.0.jar'
+
+  store:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: store-dev
+    mem_limit: 700M         # 对齐 test 用量比例,留 15~20% 余量
+    mem_reservation: 384M   # 软性内存限制
+    environment:
+      <<: *proxy-env # 引用带代理的环境变量
+    volumes:
+      - ./store:/app
+      - ./store/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - /cert/wechat/:/cert/wechat/
+      - ./logs/store:/app/logs
+    ports:
+      - "28004:30004"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -Dloader.path=/app/lib -jar /app/alien-store-1.0.0.jar'
+
+  second:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: second-dev
+    environment:
+      <<: *proxy-env
+    volumes:
+      - ./second:/app
+      - ./second/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - ./logs/second:/app/logs
+    ports:
+      - "28005:30005"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -jar /app/alien-second-1.0.0.jar'
+
+  store-platform:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: store-platform-dev
+    environment:
+      <<: *proxy-env
+    volumes:
+      - ./store-platform:/app
+      - ./store-platform/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - ./logs/store-platform:/app/logs
+    ports:
+      - "28006:30006"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -jar /app/alien-store-platform-1.0.0.jar'
+
+  lawyer:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: lawyer-dev
+    environment:
+      <<: *proxy-env
+    volumes:
+      - ./lawyer:/app
+      - ./lawyer/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - ./logs/lawyer:/app/logs
+    ports:
+      - "28007:30007"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -jar /app/alien-lawyer-1.0.0.jar'
+
+  job:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: job-dev
+    environment:
+      <<: *proxy-env
+    volumes:
+      - ./job:/app
+      - ./job/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - ./logs/job:/app/logs
+    ports:
+      - "28008:30008"
+      - "28018:9999"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -jar /app/alien-job-1.0.0.jar'
+
+  dining:
+    <<: *java-common
+    image: my-openjdk8-ffmpeg:v1
+    container_name: dining-dev
+    environment:
+      <<: *proxy-env # 引用带代理的环境变量
+    volumes:
+      - ./dining:/app
+      - ./dining/alien/aliPayCert:/usr/local/alien/aliPayCert
+      - /cert/wechat/:/cert/wechat/
+      - ./logs/dining:/app/logs
+    ports:
+      - "28014:30014"
+    command: |
+      sh -c 'exec java -Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Xms384m -Xmx384m -Dloader.path=/app/lib -jar /app/alien-dining-1.0.0.jar'
+
+
+networks:
+  app-network:
+    external: true        # 使用同一个外部网络
+    name: app-network

+ 69 - 0
docs/devops/dev/middleware/docker-compose.yml

@@ -0,0 +1,69 @@
+# =============================================================================
+# 开发环境 — 增补中间件(与 /docker/middleware 并行,共用 app-network)
+# =============================================================================
+#
+# 【设计原则】
+#   共用(不重复部署):MySQL、Nacos、RocketMQ、xxl-job-admin、Jenkins、ES
+#   隔离(本文件新增):redis-dev(避免 dev/test session/缓存/限流互相污染)
+#
+# 【服务器部署路径建议】
+#   /docker/middleware-dev/   或复制本目录内容到服务器
+#
+# 【启动】
+#   cd /docker/middleware-dev
+#   docker compose up -d
+#
+# 【或叠加到现有 middleware 目录】
+#   cd /docker/middleware
+#   docker compose -f docker-compose.yml -f /docker/middleware-dev/docker-compose.yml up -d redis-dev
+#
+# 【Nacos dev 命名空间 common.yml 需配置】
+#   spring.redis.host: redis-dev
+#   spring.redis.port: 6379
+#   spring.redis.password: Alien123456
+#   (容器内通过服务名访问,勿写 120.26.186.130:30022)
+#
+# 【宿主机 / 本地 IDEA 直连】
+#   host: 120.26.186.130  port: 30022
+#
+# =============================================================================
+
+x-logging: &default-logging
+  driver: "json-file"
+  options:
+    max-size: "50m"
+    max-file: "3"
+
+services:
+  # ---------------------------------------------------------------------------
+  # redis-dev — 开发环境专用 Redis
+  # 配置与 test redis 一致(requirepass/maxmemory 256mb);mem_limit 对齐 test 512M
+  # ---------------------------------------------------------------------------
+  redis-dev:
+    image: redis:6.0.8
+    container_name: redis-dev
+    restart: unless-stopped
+    logging: *default-logging
+    environment:
+      - TZ=Asia/Shanghai
+    volumes:
+      - ./redis-dev/data:/data
+      - ./redis-dev/conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
+    ports:
+      - "20022:6379"
+    networks:
+      - app-network
+    command: redis-server /usr/local/etc/redis/redis.conf
+    mem_limit: 512M
+    mem_reservation: 256M
+    healthcheck:
+      test: ["CMD", "redis-cli", "-a", "Alien123456", "ping"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 10s
+
+networks:
+  app-network:
+    external: true
+    name: app-network

+ 13 - 0
docs/devops/dev/middleware/redis-dev/conf/redis.conf

@@ -0,0 +1,13 @@
+# 开发环境 Redis(redis-dev)
+# 容器内 6379,宿主机 30022;配置与 test redis 对齐,仅实例隔离
+# Nacos dev common.yml: host=redis-dev, port=6379, password=Alien123456
+
+bind 0.0.0.0
+port 6379
+requirepass Alien123456
+appendonly yes
+appendfsync everysec
+maxmemory 256mb
+maxmemory-policy allkeys-lru
+timeout 300
+tcp-keepalive 60