Explorar el Código

Merge branch 'sit' into sit-lutong-dev

# Conflicts:
#	alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
lutong hace 14 horas
padre
commit
20b87cf02f
Se han modificado 53 ficheros con 4775 adiciones y 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. 152 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. 109 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. 7 0
      alien-entity/src/main/java/shop/alien/mapper/CommonPushTaskUserMapper.java
  22. 13 1
      alien-gateway/src/main/java/shop/alien/gateway/controller/LifeUserController.java
  23. 30 0
      alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java
  24. 8 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  25. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushScheduledJob.java
  26. 7 0
      alien-store/pom.xml
  27. 21 0
      alien-store/src/main/java/shop/alien/store/config/CommonPushProperties.java
  28. 99 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushChannelConfigController.java
  29. 76 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushReviewController.java
  30. 153 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java
  31. 33 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java
  32. 77 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskNumController.java
  33. 112 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskUserController.java
  34. 25 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java
  35. 21 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTargetDto.java
  36. 52 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTaskUserCallbackDto.java
  37. 31 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTestSendDto.java
  38. 23 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushChannelConfigService.java
  39. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushReviewService.java
  40. 37 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushSendService.java
  41. 19 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskNumService.java
  42. 49 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java
  43. 27 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskUserService.java
  44. 1 1
      alien-store/src/main/java/shop/alien/store/service/StoreCommentAppealService.java
  45. 279 0
      alien-store/src/main/java/shop/alien/store/service/channel/ApnsPushClient.java
  46. 904 0
      alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java
  47. 153 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushChannelConfigServiceImpl.java
  48. 65 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushReviewServiceImpl.java
  49. 471 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java
  50. 66 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskNumServiceImpl.java
  51. 563 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java
  52. 304 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskUserServiceImpl.java
  53. 26 26
      alien-store/src/main/java/shop/alien/store/service/impl/StoreCommentAppealServiceImpl.java

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

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

@@ -0,0 +1,152 @@
+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("删除标记,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

@@ -192,4 +192,8 @@ public class LifeUser implements Serializable {
     @TableField("is_popup")
     private Integer isPopup;
 
+    @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("实际发送数(status=2或3)")
+    private Long sentCount;
+
+    @ApiModelProperty("送达数(status=3)")
+    private Long deliveredCount;
+
+    @ApiModelProperty("点击数(user_add=1)")
+    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> {
+}

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

@@ -0,0 +1,109 @@
+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> {
+
+    @Select("<script>" +
+            "SELECT " +
+            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
+            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
+            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            "FROM common_push_task t " +
+            "LEFT JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            "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 u.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND u.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, " +
+            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
+            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
+            "IFNULL(SUM(CASE WHEN u.show_info = 1 THEN 1 ELSE 0 END), 0) AS showCount, " +
+            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            "FROM common_push_task t " +
+            "LEFT JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            "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 u.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND u.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 " +
+            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
+            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
+            "IFNULL(SUM(CASE WHEN u.show_info = 1 THEN 1 ELSE 0 END), 0) AS showCount, " +
+            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            "FROM common_push_task_user u " +
+            "WHERE u.delete_flag = 0 " +
+            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
+            "<if test='phoneType != null and phoneType != \"\"'>AND u.phone_type = #{phoneType} </if>" +
+            "</script>")
+    CommonPushTaskStatsDto selectUserStats(@Param("startTime") Date startTime,
+                                           @Param("endTime") Date endTime,
+                                           @Param("phoneType") String phoneType);
+
+    @Select("<script>" +
+            "SELECT t.id, t.title, " +
+            "IFNULL(SUM(CASE WHEN u.status IN (2, 3) THEN 1 ELSE 0 END), 0) AS sentCount, " +
+            "IFNULL(SUM(CASE WHEN u.status = 3 THEN 1 ELSE 0 END), 0) AS deliveredCount, " +
+            "IFNULL(SUM(CASE WHEN u.user_add = 1 THEN 1 ELSE 0 END), 0) AS clickCount " +
+            "FROM common_push_task t " +
+            "INNER JOIN common_push_task_user u ON u.push_task_id = t.id AND u.delete_flag = 0 " +
+            "WHERE t.delete_flag = 0 " +
+            "<if test='startTime != null'>AND u.created_time &gt;= #{startTime} </if>" +
+            "<if test='endTime != null'>AND u.created_time &lt; #{endTime} </if>" +
+            "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> {
+}

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

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

+ 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")
@@ -69,4 +68,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);
+    }
+
 }

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

@@ -11,6 +11,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;
@@ -163,6 +164,35 @@ public class LifeUserService extends ServiceImpl<LifeUserGatewayMapper, LifeUser
         return this.getOne(lambdaQueryWrapper);
     }
 
+    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(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地址)

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

@@ -112,6 +112,14 @@ public interface AlienStoreFeign {
     R<String> pollStoreCommentAppealSupplementCompletedResult();
 
     /**
+     * 执行到期的定时推送任务(sendType=2,status=审核通过,scheduledAt &lt;= now)
+     *
+     * @return R.data 为本次成功发送的任务数
+     */
+    @PostMapping("/commonPushTask/job/executeScheduled")
+    R<Integer> executeScheduledPushTasks();
+
+    /**
      * 平台埋点:零点归档昨日明细
      */
     @PostMapping("/analytics/job/archiveDaily")

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

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

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

@@ -0,0 +1,153 @@
+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;
+
+    @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 = "开始时间", dataType = "Date", paramType = "query"),
+            @ApiImplicitParam(name = "endTime", value = "结束时间", 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 = "手机设备类型,筛选 common_push_task_user.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("报表中心(日报/周报/月报)")
+    @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);
+    }
+}

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

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

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

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

@@ -0,0 +1,25 @@
+package shop.alien.store.dto;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 推送发送结果
+ */
+@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<>();
+}

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

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

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

@@ -0,0 +1,49 @@
+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();
+    R<CommonPushReportVo> report(Integer reportType);
+}

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

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

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

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

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

@@ -0,0 +1,904 @@
+package shop.alien.store.service.channel;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+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.CommonPushTargetDto;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 各厂商推送 HTTP 客户端,凭证从配置表 credential_json 读取。
+ */
+@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;
+    private static final String HUAWEI_PUSH_BASE_URL = "https://push-api.cloud.huawei.com";
+    private static final String HONOR_PUSH_BASE_URL = "https://push-api.cloud.honor.com";
+    private static final String HONOR_AUTH_URL =
+            "https://auth.honor.com/auth/realms/developer/protocol/openid-connect/token";
+
+    private final CommonPushProperties commonPushProperties;
+    private final ApnsPushClient apnsPushClient;
+
+    private static final RestTemplate restTemplate = new RestTemplate();
+
+    /**
+     * OPPO 全量广播推送(target_type=1),用于 targetType=1 全量场景。
+     */
+    public boolean sendOppoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        try {
+            String authToken = obtainOppoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return false;
+            }
+            String messageId = saveOppoMessageContent(authToken, task, null);
+            if (StringUtils.isBlank(messageId)) {
+                return false;
+            }
+            return broadcastOppoMessage(authToken, messageId);
+        } catch (Exception e) {
+            log.error("OPPO 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 小米全量广播推送,用于 targetType=1 全量场景。
+     */
+    public boolean sendXiaomiBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isBlank(appSecret)) {
+            return false;
+        }
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            return false;
+        }
+        try {
+            MultiValueMap<String, String> form = buildXiaomiMessageForm(credential, task, null);
+            Map<String, String> headers = new HashMap<>();
+            headers.put("Authorization", "key=" + appSecret);
+            String resp = postForm("https://api.xmpush.xiaomi.com/v3/message/all", form, headers);
+            return isXiaomiSendSuccess(resp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("小米广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * vivo 全量广播推送,用于 targetType=1 全量场景。
+     */
+    public boolean sendVivoBroadcast(CommonPushChannelConfig config, CommonPushTask task) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        try {
+            String authToken = obtainVivoAuthToken(credential);
+            if (StringUtils.isBlank(authToken)) {
+                return false;
+            }
+            JSONObject sendBody = buildVivoNotificationBody(credential, task, null);
+            Map<String, String> headers = new HashMap<>();
+            headers.put("authToken", authToken);
+            String sendResp = postJson("https://api-push.vivo.com.cn/message/all", sendBody.toJSONString(), headers);
+            return isVivoSendSuccess(sendResp, task.getTaskNo(), "全量广播");
+        } catch (Exception e) {
+            log.error("vivo 广播推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 华为全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public boolean sendHuaweiBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                       List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("华为全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                if (sendHuaweiLike(credential, task, batch)) {
+                    successBatches++;
+                }
+            }
+            log.info("华为全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
+                    task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
+            return successBatches > 0;
+        } catch (Exception e) {
+            log.error("华为全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 荣耀全量推送:官方无 /all 接口,按 token 批量下发(每批最多 1000 个)。
+     */
+    public boolean sendHonorBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                      List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        List<String> deviceIds = extractDeviceIds(targets);
+        if (deviceIds.isEmpty()) {
+            log.warn("荣耀全量推送无可用 deviceId, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        try {
+            int successBatches = 0;
+            int totalBatches = 0;
+            for (List<String> batch : partitionList(deviceIds, HUAWEI_BATCH_TOKEN_LIMIT)) {
+                totalBatches++;
+                if (sendHonorLike(credential, task, batch)) {
+                    successBatches++;
+                }
+            }
+            log.info("荣耀全量批量推送完成, taskNo={}, devices={}, batches={}/{}",
+                    task.getTaskNo(), deviceIds.size(), successBatches, totalBatches);
+            return successBatches > 0;
+        } catch (Exception e) {
+            log.error("荣耀全量推送失败, taskNo={}, err={}", task.getTaskNo(), e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * APNs 全量推送:按 iOS deviceToken 逐条下发(Apple 无 /all 接口)。
+     */
+    public boolean sendApnsBroadcast(CommonPushChannelConfig config, CommonPushTask task,
+                                     List<CommonPushTargetDto> targets) {
+        if (config == null || task == null) {
+            return false;
+        }
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null || !apnsPushClient.validateCredential(credential)) {
+            return false;
+        }
+        List<CommonPushTargetDto> apnsTargets = ApnsPushClient.filterApnsTargets(targets, targets);
+        if (apnsTargets.isEmpty()) {
+            log.warn("APNs 全量推送无 iOS 设备 token, taskNo={}", task.getTaskNo());
+            return false;
+        }
+        return apnsPushClient.sendBroadcast(credential, task, apnsTargets);
+    }
+
+    public boolean send(CommonPushChannelConfig config, CommonPushTask task, CommonPushTargetDto target) {
+        if (config == null || task == null || target == null || StringUtils.isBlank(target.getDeviceId())) {
+            return false;
+        }
+        String deviceId = target.getDeviceId();
+        JSONObject credential = parseCredential(config.getCredentialJson());
+        if (credential == null) {
+            return false;
+        }
+        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);
+                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 false;
+            }
+        } catch (Exception e) {
+            log.error("渠道推送失败, channelCode={}, deviceId={}, err={}", channelCode, deviceId, e.getMessage(), e);
+            return false;
+        }
+    }
+
+    private boolean sendVivo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        if (StringUtils.isBlank(deviceId)) {
+            return false;
+        }
+        String authToken = obtainVivoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
+            return false;
+        }
+        JSONObject sendBody = buildVivoNotificationBody(credential, task, target);
+        sendBody.put("regId", deviceId);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("authToken", authToken);
+        String sendResp = postJson("https://api-push.vivo.com.cn/message/send", sendBody.toJSONString(), headers);
+        return isVivoSendSuccess(sendResp, task.getTaskNo(), "单推");
+    }
+
+    private String obtainVivoAuthToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appKey = credential.getString("appKey");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appKey, appSecret)) {
+            return 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 boolean isVivoSendSuccess(String resp, String taskNo, String scene) {
+        log.info("vivo{}响应: {}", scene, resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json != null && json.getIntValue("result") == 0) {
+            return true;
+        }
+        if (json != null) {
+            log.error("vivo{}失败, taskNo={}, result={}, desc={}",
+                    scene, taskNo, json.getIntValue("result"), json.getString("desc"));
+        }
+        return false;
+    }
+
+    private boolean sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appSecret, deviceId)) {
+            return false;
+        }
+        if (!validateXiaomiCredential(credential, task.getTaskNo())) {
+            return false;
+        }
+        MultiValueMap<String, String> form = buildXiaomiMessageForm(credential, task, target);
+        form.add("registration_id", deviceId);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "key=" + appSecret);
+        String resp = postForm("https://api.xmpush.xiaomi.com/v3/message/regid", form, headers);
+        return isXiaomiSendSuccess(resp, task.getTaskNo(), "单推");
+    }
+
+    private boolean validateXiaomiCredential(JSONObject credential, String taskNo) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isBlank(channelId)) {
+            log.error("小米推送缺少 channelId, taskNo={}。小米运营平台已创建 channel 后,还需将 channel_id 写入本系统 common_push_channel_config.credentialJson.channelId",
+                    taskNo);
+            return false;
+        }
+        if (StringUtils.isBlank(credential.getString("packageName"))) {
+            log.warn("小米推送未配置 packageName, taskNo={}, channelId={},请确认与小米应用信息中的包名一致",
+                    taskNo, channelId);
+        }
+        return true;
+    }
+
+    private String resolveXiaomiChannelId(JSONObject credential) {
+        if (credential == null) {
+            return null;
+        }
+        return StringUtils.defaultIfBlank(
+                StringUtils.trimToNull(credential.getString("channelId")),
+                StringUtils.trimToNull(credential.getString("channel_id")));
+    }
+
+    private boolean isXiaomiSendSuccess(String resp, String taskNo, String scene) {
+        log.info("小米{}响应: {}", scene, resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json != null && "ok".equalsIgnoreCase(json.getString("result"))) {
+            return true;
+        }
+        if (json != null) {
+            log.error("小米{}失败, taskNo={}, code={}, reason={}, description={}",
+                    scene, taskNo, json.getString("code"), json.getString("reason"), json.getString("description"));
+        }
+        return false;
+    }
+
+    private MultiValueMap<String, String> buildXiaomiMessageForm(JSONObject credential, CommonPushTask task,
+                                                                   CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("title", task.getTitle());
+        form.add("description", task.getContent());
+        form.add("payload", buildPayload(task));
+        String packageName = credential.getString("packageName");
+        if (StringUtils.isNotBlank(packageName)) {
+            form.add("restricted_package_name", packageName.trim());
+        } else {
+            log.warn("小米推送未配置 packageName,多包名应用可能返回 22022 或 27001");
+        }
+        appendXiaomiExtraParams(form, credential, task, target);
+        return form;
+    }
+
+    /**
+     * 小米 HTTP API 要求以 extra.xxx 形式传参(非 extra JSON 字符串)。
+     */
+    private void appendXiaomiExtraParams(MultiValueMap<String, String> form, JSONObject credential,
+                                           CommonPushTask task, CommonPushTargetDto target) {
+        String channelId = resolveXiaomiChannelId(credential);
+        if (StringUtils.isNotBlank(channelId)) {
+            form.add("extra.channel_id", channelId);
+        }
+        form.add("extra.callback", resolveCallbackUrl());
+        form.add("extra.callback.param", buildXiaomiCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("extra.notify_effect", "3");
+            form.add("extra.web_uri", task.getJumpUrl());
+        }
+    }
+
+    private String buildXiaomiCallbackParameter(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target != null && target.getUserId() != null) {
+            param.put("u", target.getUserId());
+        }
+        param.put("c", "xiaomi");
+        return param.toJSONString();
+    }
+
+    /**
+     * OPPO 厂商直连:使用 RegistrationID(非 UniPush CID),并设置送达回执回调。
+     */
+    private boolean sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String authToken = obtainOppoAuthToken(credential);
+        if (StringUtils.isBlank(authToken)) {
+            return false;
+        }
+
+        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);
+            JSONObject resp = JSONObject.parseObject(body);
+            return resp != null && resp.getIntValue("code") == 0;
+        } catch (Exception e) {
+            log.error("OPPO 单推请求异常: {}", e.getMessage(), e);
+            return false;
+        }
+    }
+
+    private String obtainOppoAuthToken(JSONObject credential) {
+        String appKey = credential.getString("appKey");
+        String masterSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appKey, masterSecret)) {
+            return null;
+        }
+        long timestamp = System.currentTimeMillis();
+        String sign = cn.hutool.crypto.SecureUtil.sha256(appKey + timestamp + masterSecret);
+        MultiValueMap<String, String> authForm = new LinkedMultiValueMap<>();
+        authForm.add("app_key", appKey);
+        authForm.add("timestamp", String.valueOf(timestamp));
+        authForm.add("sign", sign);
+        String authResp = postForm("https://api.push.oppomobile.com/server/v1/auth", authForm, null);
+        JSONObject authJson = JSONObject.parseObject(authResp);
+        if (authJson == null || authJson.getJSONObject("data") == null) {
+            log.warn("OPPO 鉴权失败: {}", authResp);
+            return null;
+        }
+        return authJson.getJSONObject("data").getString("auth_token");
+    }
+
+    private JSONObject buildOppoNotification(CommonPushTask task, CommonPushTargetDto target) {
+        JSONObject notification = new JSONObject();
+        notification.put("title", task.getTitle());
+        notification.put("content", task.getContent());
+        notification.put("call_back_url", resolveCallbackUrl());
+        notification.put("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            notification.put("click_action_url", task.getJumpUrl());
+        }
+        return notification;
+    }
+
+    /**
+     * 保存 OPPO 广播消息体,返回 message_id。
+     */
+    private String saveOppoMessageContent(String authToken, CommonPushTask task, CommonPushTargetDto target) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("title", task.getTitle());
+        form.add("content", task.getContent());
+        form.add("call_back_url", resolveCallbackUrl());
+        form.add("call_back_parameter", buildCallbackParameter(task, target));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            form.add("click_action_url", task.getJumpUrl());
+        }
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/save_message_content", form, null);
+        log.info("OPPO 保存广播消息体响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        if (json == null || json.getJSONObject("data") == null) {
+            return null;
+        }
+        return json.getJSONObject("data").getString("message_id");
+    }
+
+    /**
+     * OPPO 广播下发,target_type=1 表示全量用户。
+     */
+    private boolean broadcastOppoMessage(String authToken, String messageId) {
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("auth_token", authToken);
+        form.add("message_id", messageId);
+        form.add("target_type", "1");
+        String resp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/broadcast", form, null);
+        log.info("OPPO 广播推送响应: {}", resp);
+        JSONObject json = JSONObject.parseObject(resp);
+        return json != null && json.getIntValue("code") == 0;
+    }
+
+    private String resolveCallbackUrl() {
+        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 boolean sendHuawei(JSONObject credential, CommonPushTask task, String deviceId) {
+        return sendHuaweiLike(credential, task, Collections.singletonList(deviceId));
+    }
+
+    private boolean sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
+        return sendHonorLike(credential, task, Collections.singletonList(deviceId));
+    }
+
+    private boolean sendHuaweiLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return false;
+        }
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            return false;
+        }
+        String accessToken = obtainHuaweiAccessToken(credential);
+        if (StringUtils.isBlank(accessToken)) {
+            return false;
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(HUAWEI_PUSH_BASE_URL + "/v1/" + appId + "/messages:send",
+                body.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        boolean ok = sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+        if (!ok) {
+            log.error("华为推送失败, taskNo={}, deviceCount={}, resp={}",
+                    task.getTaskNo(), deviceIds.size(), sendResp);
+        }
+        return ok;
+    }
+
+    private boolean sendHonorLike(JSONObject credential, CommonPushTask task, List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return false;
+        }
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            return false;
+        }
+        String accessToken = obtainHonorAccessToken(credential);
+        if (StringUtils.isBlank(accessToken)) {
+            return false;
+        }
+
+        JSONObject body = buildHuaweiMessageBody(task, deviceIds);
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(HONOR_PUSH_BASE_URL + "/v1/" + appId + "/messages:send",
+                body.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        boolean ok = sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+        if (!ok) {
+            log.error("荣耀推送失败, taskNo={}, deviceCount={}, resp={}",
+                    task.getTaskNo(), deviceIds.size(), sendResp);
+        }
+        return ok;
+    }
+
+    private String obtainHuaweiAccessToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
+        tokenForm.add("grant_type", "client_credentials");
+        tokenForm.add("client_id", appId);
+        tokenForm.add("client_secret", appSecret);
+        String tokenResp = postForm(HUAWEI_PUSH_BASE_URL + "/oauth2/v2/token", tokenForm, null);
+        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+            log.warn("华为鉴权失败: {}", tokenResp);
+            return null;
+        }
+        return tokenJson.getString("access_token");
+    }
+
+    private String obtainHonorAccessToken(JSONObject credential) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
+        tokenForm.add("grant_type", "client_credentials");
+        tokenForm.add("client_id", appId);
+        tokenForm.add("client_secret", appSecret);
+        String tokenResp = postForm(HONOR_AUTH_URL, tokenForm, null);
+        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+            log.warn("荣耀鉴权失败: {}", tokenResp);
+            return null;
+        }
+        return tokenJson.getString("access_token");
+    }
+
+    private JSONObject buildHuaweiMessageBody(CommonPushTask task, List<String> deviceIds) {
+        JSONObject body = new JSONObject();
+        JSONObject message = new JSONObject();
+        JSONObject android = new JSONObject();
+        JSONObject notification = new JSONObject();
+        notification.put("title", task.getTitle());
+        notification.put("body", task.getContent());
+        android.put("notification", notification);
+        message.put("android", android);
+        body.put("message", message);
+        body.put("token", deviceIds.toArray(new String[0]));
+        return body;
+    }
+
+    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 boolean sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
+        if (!apnsPushClient.validateCredential(credential)) {
+            log.warn("APNs 凭证不完整, deviceId={}", deviceId);
+            return false;
+        }
+        return apnsPushClient.send(credential, task, deviceId);
+    }
+
+    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();
+    }
+
+    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));
+    }
+}

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

@@ -0,0 +1,471 @@
+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.service.CommonPushSendService;
+import shop.alien.store.service.CommonPushTaskUserService;
+import shop.alien.store.service.channel.ApnsPushClient;
+import shop.alien.store.service.channel.CommonPushVendorHttpClient;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+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;
+
+    @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;
+    }
+
+    @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();
+    }
+
+    private LambdaQueryWrapper<LifeUser> deviceIdNotBlankWrapper() {
+        return new LambdaQueryWrapper<LifeUser>()
+                .isNotNull(LifeUser::getDeviceId)
+                .ne(LifeUser::getDeviceId, "");
+    }
+
+    @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;
+    }
+
+    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;
+        Set<String> broadcastHandledChannels = new HashSet<>();
+        if (fullBroadcast) {
+            for (String channelCode : FULL_BROADCAST_CHANNELS) {
+                if (!channelMap.containsKey(channelCode)) {
+                    continue;
+                }
+                broadcastHandledChannels.add(channelCode);
+                CommonPushChannelConfig channelConfig = channelMap.get(channelCode);
+                List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+                List<CommonPushTargetDto> broadcastTargets = "apns".equals(channelCode)
+                        ? ApnsPushClient.filterApnsTargets(channelTargets, targets)
+                        : channelTargets;
+                boolean ok = invokeFullBroadcast(channelCode, channelConfig, task, broadcastTargets);
+                sentCount += applyFullBroadcastResult(ok, channelCode, grouped, task, result, channelConfig);
+                failedCount += countFullBroadcastFailure(ok, channelCode, grouped);
+            }
+        }
+
+        for (Map.Entry<String, List<CommonPushTargetDto>> entry : grouped.entrySet()) {
+            if (broadcastHandledChannels.contains(entry.getKey())) {
+                continue;
+            }
+            CommonPushChannelConfig channelConfig = channelMap.get(entry.getKey());
+            if (channelConfig == null) {
+                failedCount += entry.getValue().size();
+                result.getFailedTargets().addAll(entry.getValue());
+                continue;
+            }
+            for (CommonPushTargetDto target : entry.getValue()) {
+                boolean ok;
+                try {
+                    ok = commonPushVendorHttpClient.send(channelConfig, task, target);
+                } catch (Exception e) {
+                    log.error("渠道单推异常, channelCode={}, deviceId={}", entry.getKey(), target.getDeviceId(), e);
+                    ok = false;
+                }
+                if (ok) {
+                    sentCount++;
+                    result.getSentTargets().add(target);
+                    updateChannelUsage(channelConfig);
+                } else {
+                    failedCount++;
+                    result.getFailedTargets().add(target);
+                }
+            }
+        }
+        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;
+    }
+
+    private int defaultCount(Integer count) {
+        return count == null || count <= 0 ? 1 : count;
+    }
+
+    private int applyFullBroadcastResult(boolean ok, String channelCode,
+                                         Map<String, List<CommonPushTargetDto>> grouped,
+                                         CommonPushTask task, CommonPushSendResultDto result,
+                                         CommonPushChannelConfig channelConfig) {
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        if (ok) {
+            int broadcastCount = channelTargets.isEmpty()
+                    ? defaultCount(task.getEstimatedCount())
+                    : channelTargets.size();
+            if (channelTargets.isEmpty()) {
+                CommonPushTargetDto broadcastTarget = new CommonPushTargetDto();
+                broadcastTarget.setPlatform("android");
+                broadcastTarget.setBroadcast(true);
+                result.getSentTargets().add(broadcastTarget);
+            } else {
+                result.getSentTargets().addAll(channelTargets);
+            }
+            updateChannelUsageSafely(channelConfig, channelCode);
+            return broadcastCount;
+        }
+        if (!channelTargets.isEmpty()) {
+            result.getFailedTargets().addAll(channelTargets);
+        }
+        return 0;
+    }
+
+    private boolean invokeFullBroadcast(String channelCode, CommonPushChannelConfig config, CommonPushTask task,
+                                          List<CommonPushTargetDto> channelTargets) {
+        try {
+            switch (channelCode) {
+                case "apns":
+                    return commonPushVendorHttpClient.sendApnsBroadcast(config, task, channelTargets);
+                case "huawei":
+                    return commonPushVendorHttpClient.sendHuaweiBroadcast(config, task, channelTargets);
+                case "honor":
+                    return commonPushVendorHttpClient.sendHonorBroadcast(config, task, channelTargets);
+                case "xiaomi":
+                    return commonPushVendorHttpClient.sendXiaomiBroadcast(config, task);
+                case "oppo":
+                    return commonPushVendorHttpClient.sendOppoBroadcast(config, task);
+                case "vivo":
+                    return commonPushVendorHttpClient.sendVivoBroadcast(config, task);
+                default:
+                    return false;
+            }
+        } catch (Exception e) {
+            log.error("全量广播推送异常, channelCode={}, taskNo={}", channelCode, task.getTaskNo(), e);
+            return false;
+        }
+    }
+
+    private void updateChannelUsageSafely(CommonPushChannelConfig config, String channelCode) {
+        try {
+            updateChannelUsage(config);
+        } catch (Exception e) {
+            log.warn("更新渠道用量失败, channelCode={}", channelCode, e);
+        }
+    }
+
+    private int countFullBroadcastFailure(boolean ok, String channelCode,
+                                          Map<String, List<CommonPushTargetDto>> grouped) {
+        if (ok) {
+            return 0;
+        }
+        List<CommonPushTargetDto> channelTargets = grouped.getOrDefault(channelCode, Collections.emptyList());
+        return channelTargets.isEmpty() ? 1 : channelTargets.size();
+    }
+
+    @Override
+    public void saveTaskUserRecords(Long pushTaskId, CommonPushSendResultDto sendResult) {
+        if (pushTaskId == null || sendResult == null) {
+            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);
+        }
+    }
+
+    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);
+    }
+
+    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());
+            target.setPlatform("android");
+            targets.add(target);
+        }
+        return targets;
+    }
+
+    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;
+    }
+
+    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);
+    }
+
+    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());
+    }
+
+    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();
+    }
+
+    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));
+    }
+}

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

@@ -0,0 +1,563 @@
+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.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;
+
+    @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;
+    }
+
+    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());
+        }
+    }
+
+    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);
+        this.updateById(task);
+        commonPushSendService.saveTaskUserRecords(task.getId(), sendResult);
+        return R.success(sendResult.getMessage());
+    }
+
+    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());
+    }
+
+    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;
+    }
+
+    /**
+     * @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;
+    }
+
+    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);
+    }
+
+    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);
+        Page<CommonPushTaskStatsDto> page = new Page<>(pageNum, pageSize);
+        IPage<CommonPushTaskStatsDto> statsPage = baseMapper.selectStatisticsPage(page, taskNo, keyword, status, pushType, channel, startTime, endTime);
+
+        CommonPushTaskStatsDto summaryStats = baseMapper.selectStatisticsSummary(taskNo, keyword, status, pushType, channel, startTime, endTime);
+
+        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();
+    }
+
+    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);
+    }
+}

+ 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

@@ -36,8 +36,8 @@ import shop.alien.util.common.safe.TextModerationUtil;
 
 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;
@@ -727,51 +727,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);
     }
 
     /**