Ver Fonte

feat(push): 新增推送任务管理和渠道配置功能

- 添加定时推送任务执行接口和实现
- 实现推送渠道启用/停用和连通性测试功能
- 添加推送任务草稿保存和测试推送功能
- 实现推送任务AI内容审核和状态管理
- 添加推送状态回调处理和厂商回执解析
- 更新推送任务状态枚举和实体定义
- 集成IP连接测试工具用于渠道连通性检测
fcw há 5 horas atrás
pai
commit
5deb2cd9df
25 ficheiros alterados com 1806 adições e 10 exclusões
  1. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTask.java
  2. 25 0
      alien-entity/src/main/java/shop/alien/entity/store/CommonPushTaskStatus.java
  3. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/LifeUser.java
  4. 13 1
      alien-gateway/src/main/java/shop/alien/gateway/controller/LifeUserController.java
  5. 30 0
      alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java
  6. 8 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  7. 38 0
      alien-job/src/main/java/shop/alien/job/store/CommonPushScheduledJob.java
  8. 21 0
      alien-store/src/main/java/shop/alien/store/config/CommonPushProperties.java
  9. 21 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushChannelConfigController.java
  10. 22 6
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java
  11. 33 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskJobController.java
  12. 34 0
      alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskUserController.java
  13. 25 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushSendResultDto.java
  14. 18 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTargetDto.java
  15. 52 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTaskUserCallbackDto.java
  16. 31 0
      alien-store/src/main/java/shop/alien/store/dto/CommonPushTestSendDto.java
  17. 4 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushChannelConfigService.java
  18. 37 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushSendService.java
  19. 17 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskService.java
  20. 8 0
      alien-store/src/main/java/shop/alien/store/service/CommonPushTaskUserService.java
  21. 460 0
      alien-store/src/main/java/shop/alien/store/service/channel/CommonPushVendorHttpClient.java
  22. 84 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushChannelConfigServiceImpl.java
  23. 340 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushSendServiceImpl.java
  24. 245 2
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java
  25. 235 0
      alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskUserServiceImpl.java

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

@@ -115,7 +115,7 @@ public class CommonPushTask implements Serializable {
     @TableField("priority")
     private Integer priority;
 
-    @ApiModelProperty("任务状态(状态机)")
+    @ApiModelProperty("任务状态:0-草稿 1-待审核 2-已发送 3-已送达 4-失败 5-审核通过 6-驳回")
     @TableField("status")
     private Integer status;
 

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

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

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

+ 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;
@@ -161,6 +162,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

@@ -110,4 +110,12 @@ public interface AlienStoreFeign {
      */
     @PostMapping("/storeCommentAppealSupplement/job/pollCompletedResult")
     R<String> pollStoreCommentAppealSupplementCompletedResult();
+
+    /**
+     * 执行到期的定时推送任务(sendType=2,status=审核通过,scheduledAt &lt;= now)
+     *
+     * @return R.data 为本次成功发送的任务数
+     */
+    @PostMapping("/commonPushTask/job/executeScheduled")
+    R<Integer> executeScheduledPushTasks();
 }

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

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

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

@@ -75,4 +75,25 @@ public class CommonPushChannelConfigController {
             @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);
+    }
 }

+ 22 - 6
alien-store/src/main/java/shop/alien/store/controller/CommonPushTaskController.java

@@ -14,6 +14,8 @@ 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;
@@ -35,8 +37,15 @@ public class CommonPushTaskController {
         return commonPushTaskService.add(task);
     }
 
-    @ApiOperation("根据主键删除(逻辑删除)")
+    @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)
     })
@@ -46,14 +55,14 @@ public class CommonPushTaskController {
     }
 
     @ApiOperation("更新推送任务")
-    @ApiOperationSupport(order = 3)
+    @ApiOperationSupport(order = 4)
     @PostMapping("/update")
     public R<String> update(@RequestBody CommonPushTask task) {
         return commonPushTaskService.update(task);
     }
 
     @ApiOperation("根据主键查询")
-    @ApiOperationSupport(order = 4)
+    @ApiOperationSupport(order = 5)
     @ApiImplicitParams({
             @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
     })
@@ -63,13 +72,13 @@ public class CommonPushTaskController {
     }
 
     @ApiOperation("分页列表")
-    @ApiOperationSupport(order = 5)
+    @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 = "任务状态", 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")
@@ -90,7 +99,7 @@ public class CommonPushTaskController {
             @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 = "任务状态", 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"),
@@ -125,6 +134,13 @@ public class CommonPushTaskController {
         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({

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

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

@@ -8,9 +8,11 @@ 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 = {"推送任务用户管理"})
@@ -75,4 +77,36 @@ public class CommonPushTaskUserController {
             @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<>();
+}

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

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

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

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

@@ -16,4 +16,8 @@ public interface CommonPushChannelConfigService extends IService<CommonPushChann
     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);
 }

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

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

@@ -7,6 +7,8 @@ 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;
 
@@ -14,6 +16,11 @@ 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);
@@ -28,5 +35,15 @@ public interface CommonPushTaskService extends IService<CommonPushTask> {
 
     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);
 }

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

@@ -4,6 +4,7 @@ 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> {
 
@@ -16,4 +17,11 @@ public interface CommonPushTaskUserService extends IService<CommonPushTaskUser>
     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);
 }

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

@@ -0,0 +1,460 @@
+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.HashMap;
+import java.util.Map;
+
+/**
+ * 各厂商推送 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 final CommonPushProperties commonPushProperties;
+
+    private static final RestTemplate restTemplate = new RestTemplate();
+
+    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();
+        String appId = credential.getString("appId");
+        String appKey = credential.getString("appKey");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appKey, appSecret)) {
+            return false;
+        }
+        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) {
+            return false;
+        }
+        String authToken = authJson.getString("authToken");
+
+        JSONObject sendBody = new JSONObject();
+        sendBody.put("appId", appId);
+        sendBody.put("regId", deviceId);
+        sendBody.put("notifyType", 1);
+        sendBody.put("title", task.getTitle());
+        sendBody.put("content", task.getContent());
+        sendBody.put("skipType", 1);
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            sendBody.put("skipContent", task.getJumpUrl());
+        }
+        Map<String, String> headers = new HashMap<>();
+        headers.put("authToken", authToken);
+        String sendResp = postJson("https://api-push.vivo.com.cn/message/send", sendBody.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        return sendJson != null && sendJson.getIntValue("result") == 0;
+    }
+
+    private boolean sendXiaomi(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) {
+        String deviceId = target.getDeviceId();
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isBlank(appSecret)) {
+            return false;
+        }
+        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
+        form.add("registration_id", deviceId);
+        form.add("title", task.getTitle());
+        form.add("description", task.getContent());
+        form.add("payload", buildPayload(task));
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "key=" + appSecret);
+        String resp = postForm("https://api.xmpush.xiaomi.com/v3/message/regid", form, headers);
+        JSONObject json = JSONObject.parseObject(resp);
+        return json != null && "ok".equalsIgnoreCase(json.getString("result"));
+    }
+
+    /**
+     * OPPO 厂商直连:使用 RegistrationID(非 UniPush CID),并设置送达回执回调。
+     */
+    private boolean sendOppo(JSONObject credential, CommonPushTask task, CommonPushTargetDto target) throws NoSuchAlgorithmException {
+        String deviceId = target.getDeviceId();
+        String appKey = credential.getString("appKey");
+        String masterSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appKey, masterSecret)) {
+            return false;
+        }
+        long timestamp = System.currentTimeMillis();
+//        String sign = md5Hex(appKey + timestamp + masterSecret);
+        String sign = cn.hutool.crypto.SecureUtil.sha256(appKey + timestamp + masterSecret);
+        // 正确的签名生成
+//        String signStr = appKey + timestamp + masterSecret;
+//        MessageDigest digest = MessageDigest.getInstance("SHA-256");
+//        byte[] hash = digest.digest(signStr.getBytes(StandardCharsets.UTF_8));
+//        // 转小写十六进制字符串
+//        String sign = Hex.encodeHexString(hash);
+        MultiValueMap<String, String> authForm = new LinkedMultiValueMap<>();
+        authForm.add("app_key", appKey);
+        authForm.add("timestamp", String.valueOf(timestamp));
+        authForm.add("sign", sign);
+        String authResp = postForm("https://api.push.oppomobile.com/server/v1/auth", authForm, null);
+        JSONObject authJson = JSONObject.parseObject(authResp);
+        if (authJson == null || authJson.getJSONObject("data") == null) {
+            return false;
+        }
+        String authToken = authJson.getJSONObject("data").getString("auth_token");
+
+        JSONObject notification = new JSONObject();
+        notification.put("title", task.getTitle());
+        notification.put("content", task.getContent());
+        notification.put("call_back_url", resolveCallbackUrl());
+        notification.put("call_back_parameter", buildCallbackParameter(task, target, "oppo"));
+        if (StringUtils.isNotBlank(task.getJumpUrl())) {
+            notification.put("click_action_url", task.getJumpUrl());
+        }
+// 2. 构造最外层message完整JSON(官方要求顶层结构)
+        JSONObject messageRoot = new JSONObject();
+//        messageRoot.put("auth_token", authToken);
+        messageRoot.put("target_type", 2); // 固定2=registration_id单设备推送
+        messageRoot.put("target_value", deviceId);
+        messageRoot.put("notification", notification);
+
+        // 3. 表单只放一个key:message,值为完整JSON字符串
+        MultiValueMap<String, String> sendForm = new LinkedMultiValueMap<>();
+        sendForm.add("auth_token", authToken); // 鉴权令牌放在表单顶层
+        sendForm.add("message", messageRoot.toJSONString());
+
+        HttpHeaders headers = new HttpHeaders();
+//        // 显式指定UTF-8编码,避免中文乱码导致JSON解析失败
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+////        headers.setContentType(MediaType.parseMediaType("application/x-www-form-urlencoded;charset=UTF-8"));
+        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(sendForm, headers);
+        try {
+            ResponseEntity<String> response = restTemplate.postForEntity(
+                    "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;
+        }
+
+//        String sendResp = postForm("https://api.push.oppomobile.com/server/v1/message/notification/unicast", sendForm, null);
+//        JSONObject sendJson = JSONObject.parseObject(sendResp);
+//        return sendJson != null && sendJson.getIntValue("code") == 0;
+    }
+
+    private 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, String channelCode) {
+        JSONObject param = new JSONObject(true);
+        if (task.getId() != null) {
+            param.put("p", task.getId());
+        } else if (StringUtils.isNotBlank(task.getTaskNo())) {
+            param.put("t", task.getTaskNo().trim());
+        }
+        if (target.getUserId() != null) {
+            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, deviceId, "https://push-api.cloud.huawei.com");
+    }
+
+    private boolean sendHonor(JSONObject credential, CommonPushTask task, String deviceId) {
+        return sendHuaweiLike(credential, task, deviceId, "https://push-api.cloud.huawei.com");
+    }
+
+    private boolean sendHuaweiLike(JSONObject credential, CommonPushTask task, String deviceId, String baseUrl) {
+        String appId = credential.getString("appId");
+        String appSecret = credential.getString("appSecret");
+        if (StringUtils.isAnyBlank(appId, appSecret)) {
+            return false;
+        }
+        MultiValueMap<String, String> tokenForm = new LinkedMultiValueMap<>();
+        tokenForm.add("grant_type", "client_credentials");
+        tokenForm.add("client_id", appId);
+        tokenForm.add("client_secret", appSecret);
+        String tokenResp = postForm(baseUrl + "/oauth2/v2/token", tokenForm, null);
+        JSONObject tokenJson = JSONObject.parseObject(tokenResp);
+        if (tokenJson == null || StringUtils.isBlank(tokenJson.getString("access_token"))) {
+            return false;
+        }
+        String accessToken = tokenJson.getString("access_token");
+
+        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", new String[]{deviceId});
+
+        Map<String, String> headers = new HashMap<>();
+        headers.put("Authorization", "Bearer " + accessToken);
+        String sendResp = postJson(baseUrl + "/v1/" + appId + "/messages:send", body.toJSONString(), headers);
+        JSONObject sendJson = JSONObject.parseObject(sendResp);
+        return sendJson != null && StringUtils.isNotBlank(sendJson.getString("requestId"));
+    }
+
+    /**
+     * APNs 需 HTTP/2 + ES256 JWT,当前环境未接入专用客户端,仅校验凭证字段完整性。
+     */
+    private boolean sendApns(JSONObject credential, CommonPushTask task, String deviceId) {
+        String bundleId = credential.getString("bundleId");
+        String teamId = credential.getString("teamId");
+        String keyId = credential.getString("keyId");
+        String privateKey = credential.getString("privateKey");
+        if (StringUtils.isAnyBlank(bundleId, teamId, keyId, privateKey)) {
+            log.warn("APNs 凭证不完整, deviceId={}", deviceId);
+            return false;
+        }
+        log.warn("APNs 推送需 HTTP/2 客户端支持,当前跳过实际下发, deviceId={}, title={}", deviceId, task.getTitle());
+        return false;
+    }
+
+    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);
+        }
+    }
+}

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

@@ -1,5 +1,6 @@
 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;
@@ -12,12 +13,29 @@ 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.huawei.com");
+        CHANNEL_TEST_HOST.put("samsung", "apchina.push.samsungosp.com.cn");
+    }
+
     @Override
     public R<String> add(CommonPushChannelConfig config) {
         log.info("CommonPushChannelConfigServiceImpl.add, param={}", config);
@@ -66,4 +84,70 @@ public class CommonPushChannelConfigServiceImpl extends ServiceImpl<CommonPushCh
         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());
+    }
 }

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

@@ -0,0 +1,340 @@
+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.CommonPushVendorHttpClient;
+
+import java.util.ArrayList;
+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 {
+
+    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;
+        }
+        if (targets == null || targets.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));
+        Map<String, List<CommonPushTargetDto>> grouped = groupTargetsByChannel(targets, channelMap, task.getTargetConfig());
+
+        int sentCount = 0;
+        int failedCount = 0;
+        for (Map.Entry<String, List<CommonPushTargetDto>> entry : grouped.entrySet()) {
+            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 = commonPushVendorHttpClient.send(channelConfig, task, target);
+                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);
+        result.setMessage(sentCount > 0 ? "推送完成" : "推送失败,请检查渠道凭证与设备 token");
+        return result;
+    }
+
+    @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) {
+            CommonPushTaskUser taskUser = new CommonPushTaskUser();
+            taskUser.setPushTaskId(pushTaskId);
+            taskUser.setUserId(target.getUserId());
+            taskUser.setDeviceId(target.getDeviceId());
+            taskUser.setRelType(1);
+            taskUser.setStatus(sentDeviceIds.contains(target.getDeviceId()) ? 0 : 0);
+            taskUser.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);
+    }
+}

+ 245 - 2
alien-store/src/main/java/shop/alien/store/service/impl/CommonPushTaskServiceImpl.java

@@ -8,12 +8,20 @@ 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;
@@ -30,11 +38,246 @@ public class CommonPushTaskServiceImpl extends ServiceImpl<CommonPushTaskMapper,
 
     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);
-        boolean result = this.save(task);
-        return result ? R.success("新增成功") : R.fail("新增失败");
+        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 = commonPushSendService.send(task);
+        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

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

@@ -1,6 +1,8 @@
 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;
@@ -9,8 +11,12 @@ 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
@@ -18,6 +24,8 @@ import shop.alien.store.service.CommonPushTaskUserService;
 @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);
@@ -66,4 +74,231 @@ public class CommonPushTaskUserServiceImpl extends ServiceImpl<CommonPushTaskUse
         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;
+        }
+    }
 }