Răsfoiți Sursa

feat(reminder): 任务打卡提醒升级为可配置提醒模块

- 新增 task_reminder_config 用户级提醒配置表(迁移353, channel/timing/offset/enabled)
- 新增 TaskReminderConfigService/Controller(/api/task/reminder-config get/save)
- 重写 TaskCheckinWebhookReminderTask: 按配置支持的 before_deadline/after_timeout 双窗口, 多渠道分发
- 新增 WebhookNotificationChannel(wechat) 接入微信待办 webhook; Email 通道骨架(未启用)
- 站内信(in_app)恒兜底并作幂等标记, 兼容旧 type 判重避免重复推送
- 无配置用户走默认: wechat+超时60分钟(与线上行为一致)
- 新增 7 个单测覆盖窗口/渠道/判重/停用场景
Xiaogang Liao 6 zile în urmă
părinte
comite
b4ffea94d4

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10942,6 +10942,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         createRepurchaseReminderTables();
         createRecommendationProductAssociationsTable();
         addAbTestColumnsToRecommendationLog();
+        createTaskReminderConfigTable();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11816,4 +11817,24 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移352: 清理 REPORT_UPLOAD 配额记录失败: {}", e.getMessage());
         }
     }
+
+    // 迁移353: 任务提醒配置表(用户级,一行/用户;提醒渠道/时机/偏移分钟可配置)
+    private void createTaskReminderConfigTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_reminder_config (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID(users.id),家长账号'," +
+                    "channels VARCHAR(100) DEFAULT 'wechat' COMMENT '提醒渠道(逗号分隔): in_app/wechat/sms/phone/email'," +
+                    "timing_mode VARCHAR(20) DEFAULT 'after_timeout' COMMENT '提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒'," +
+                    "offset_minutes INT DEFAULT 60 COMMENT '偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟'," +
+                    "enabled TINYINT DEFAULT 1 COMMENT '是否启用 1=启用 0=停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "UNIQUE KEY uk_user (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表'");
+            log.info("迁移353: 已创建 task_reminder_config 表");
+        } catch (Exception e) {
+            log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
+        }
+    }
 }

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskReminderConfigController.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.controller.task;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.TaskReminderConfigDTO;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/api/task/reminder-config")
+public class TaskReminderConfigController {
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
+    @PostMapping("/get")
+    public Result<TaskReminderConfigDTO> getConfig(
+            @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) {
+            return Result.error("未登录或缺少用户身份");
+        }
+        return Result.success(taskReminderConfigService.getConfig(userId));
+    }
+
+    @PostMapping("/save")
+    public Result<Void> saveConfig(
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestBody TaskReminderConfigDTO dto) {
+        if (userId == null) {
+            return Result.error("未登录或缺少用户身份");
+        }
+        try {
+            taskReminderConfigService.saveConfig(userId, dto);
+            return Result.success(null);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TaskReminderConfigDTO.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class TaskReminderConfigDTO implements Serializable {
+
+    /** 用户勾选的提醒渠道(不含强制兜底的 in_app):wechat/sms/phone/email */
+    private List<String> channels;
+
+    /** 提醒时机: before_deadline/after_timeout */
+    private String timingMode;
+
+    /** 偏移分钟: 截止前提前N分钟 或 超时后N分钟 */
+    private Integer offsetMinutes;
+
+    /** 是否启用 1=启用 0=停用 */
+    private Integer enabled;
+
+    /** 各渠道开通状态(供配置页展示"未开通") */
+    private List<ChannelInfo> availableChannels;
+
+    @Data
+    public static class ChannelInfo implements Serializable {
+        private String type;
+        private String label;
+        private Boolean available;
+        private String reason;
+    }
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskReminderConfig.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_reminder_config")
+public class TaskReminderConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID(users.id),家长账号 */
+    private Long userId;
+
+    /** 提醒渠道(逗号分隔): in_app/wechat/sms/phone/email */
+    private String channels;
+
+    /** 提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒 */
+    private String timingMode;
+
+    /** 偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟 */
+    private Integer offsetMinutes;
+
+    /** 是否启用 1=启用 0=停用 */
+    private Integer enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskReminderConfigMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskReminderConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TaskReminderConfigMapper extends BaseMapper<TaskReminderConfig> {
+}

+ 147 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskReminderConfigService.java

@@ -0,0 +1,147 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.TaskReminderConfigDTO;
+import com.etotem.cfc.entity.TaskReminderConfig;
+import com.etotem.cfc.mapper.TaskReminderConfigMapper;
+import com.etotem.cfc.service.notification.NotificationChannel;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class TaskReminderConfigService {
+
+    /** 支持的用户可选渠道(in_app 恒兜底,不进用户选择) */
+    private static final List<String> SUPPORTED_CHANNELS = Arrays.asList("wechat", "sms", "phone", "email");
+    private static final List<String> SUPPORTED_TIMING_MODES = Arrays.asList("before_deadline", "after_timeout");
+    private static final Map<String, String> CHANNEL_LABELS = new LinkedHashMap<>();
+
+    static {
+        CHANNEL_LABELS.put("wechat", "微信");
+        CHANNEL_LABELS.put("sms", "短信");
+        CHANNEL_LABELS.put("phone", "语音电话");
+        CHANNEL_LABELS.put("email", "邮件");
+        CHANNEL_LABELS.put("in_app", "站内消息");
+    }
+
+    @Resource
+    private TaskReminderConfigMapper taskReminderConfigMapper;
+
+    @Resource
+    private List<NotificationChannel> notificationChannels;
+
+    /** 返回用户配置;未配置返回默认值(与线上行为一致:微信+超时60分钟,站内兜底) */
+    public TaskReminderConfigDTO getConfig(Long userId) {
+        TaskReminderConfig entity = selectByUserId(userId);
+        TaskReminderConfigDTO dto = new TaskReminderConfigDTO();
+        if (entity == null) {
+            dto.setChannels(new ArrayList<>(Arrays.asList("wechat")));
+            dto.setTimingMode("after_timeout");
+            dto.setOffsetMinutes(60);
+            dto.setEnabled(1);
+        } else {
+            dto.setChannels(entity.getChannels() == null || entity.getChannels().isEmpty()
+                    ? new ArrayList<>(Arrays.asList("wechat"))
+                    : new ArrayList<>(Arrays.asList(entity.getChannels().split(","))));
+            dto.setTimingMode(entity.getTimingMode());
+            dto.setOffsetMinutes(entity.getOffsetMinutes());
+            dto.setEnabled(entity.getEnabled());
+        }
+        dto.setAvailableChannels(buildChannelStatus());
+        return dto;
+    }
+
+    public void saveConfig(Long userId, TaskReminderConfigDTO dto) {
+        List<String> channels = dto.getChannels() == null ? new ArrayList<>() : dto.getChannels();
+        List<String> cleaned = new ArrayList<>();
+        for (String ch : channels) {
+            if (ch == null) continue;
+            String c = ch.trim();
+            if (SUPPORTED_CHANNELS.contains(c) && !cleaned.contains(c)) {
+                cleaned.add(c);
+            }
+        }
+        if (cleaned.isEmpty()) {
+            throw new IllegalArgumentException("至少选择一个提醒渠道");
+        }
+
+        String timingMode = dto.getTimingMode();
+        if (!SUPPORTED_TIMING_MODES.contains(timingMode)) {
+            throw new IllegalArgumentException("提醒时机不合法");
+        }
+        int offset = dto.getOffsetMinutes() == null ? 60 : dto.getOffsetMinutes();
+        if (offset < 1 || offset > 1440) {
+            throw new IllegalArgumentException("偏移分钟需在 1-1440 之间");
+        }
+
+        TaskReminderConfig entity = selectByUserId(userId);
+        boolean isNew = (entity == null);
+        if (isNew) {
+            entity = new TaskReminderConfig();
+            entity.setUserId(userId);
+            entity.setCreatedAt(new Date());
+        }
+        entity.setChannels(String.join(",", cleaned));
+        entity.setTimingMode(timingMode);
+        entity.setOffsetMinutes(offset);
+        entity.setEnabled(dto.getEnabled() != null && dto.getEnabled() == 0 ? 0 : 1);
+        entity.setUpdatedAt(new Date());
+        if (isNew) {
+            taskReminderConfigMapper.insert(entity);
+        } else {
+            taskReminderConfigMapper.updateById(entity);
+        }
+    }
+
+    /** 全量配置(调度器使用):userId -> 配置实体 */
+    public Map<Long, TaskReminderConfig> loadAllConfigs() {
+        Map<Long, TaskReminderConfig> map = new LinkedHashMap<>();
+        List<TaskReminderConfig> list = taskReminderConfigMapper.selectList(null);
+        if (list != null) {
+            for (TaskReminderConfig c : list) {
+                map.put(c.getUserId(), c);
+            }
+        }
+        return map;
+    }
+
+    private TaskReminderConfig selectByUserId(Long userId) {
+        return taskReminderConfigMapper.selectOne(new LambdaQueryWrapper<TaskReminderConfig>()
+                .eq(TaskReminderConfig::getUserId, userId)
+                .last("LIMIT 1"));
+    }
+
+    /** 汇总通道开通状态(外部通道以对应 NotificationChannel.isEnabled() 为准) */
+    private List<TaskReminderConfigDTO.ChannelInfo> buildChannelStatus() {
+        List<TaskReminderConfigDTO.ChannelInfo> result = new ArrayList<>();
+        for (Map.Entry<String, String> entry : CHANNEL_LABELS.entrySet()) {
+            TaskReminderConfigDTO.ChannelInfo info = new TaskReminderConfigDTO.ChannelInfo();
+            info.setType(entry.getKey());
+            info.setLabel(entry.getValue());
+            info.setAvailable(isChannelAvailable(entry.getKey()));
+            if (!info.getAvailable() && !"in_app".equals(entry.getKey())) {
+                info.setReason("未接入服务商网关,暂不可用");
+            }
+            result.add(info);
+        }
+        return result;
+    }
+
+    private boolean isChannelAvailable(String type) {
+        for (NotificationChannel channel : notificationChannels) {
+            if (type.equals(channel.getChannelType())) {
+                return channel.isEnabled();
+            }
+        }
+        return false;
+    }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/EmailNotificationChannel.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 邮件通知通道(外部接口,预留)
+ * <p>
+ * 未配置 SMTP/邮件服务商与收件人邮箱前不发送,仅记录日志;
+ * 启用开关: notification.channels.email.enabled(默认 false)。
+ */
+@Component
+public class EmailNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(EmailNotificationChannel.class);
+
+    @Value("${notification.channels.email.enabled:false}")
+    private boolean enabled;
+
+    @Override
+    public String getChannelType() {
+        return "email";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        log.warn("[邮件通道预留] 未配置邮件网关,跳过发送: userId={}, title={}", message.getUserId(), message.getTitle());
+    }
+}

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/WebhookNotificationChannel.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import com.etotem.cfc.service.WechatWaitMsgService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 微信通知通道(待办消息 webhook 实现,已投产)
+ * <p>
+ * 复用 WechatWaitMsgService 向微信待办接口推送;URL/token 见
+ * etotem.webhook.wait-msg.*(缺省内联默认值),msgLabel 经 sys_config 可配。
+ */
+@Component
+public class WebhookNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(WebhookNotificationChannel.class);
+
+    @Resource
+    private WechatWaitMsgService wechatWaitMsgService;
+
+    @Override
+    public String getChannelType() {
+        return "wechat";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return true;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        boolean ok = wechatWaitMsgService.sendWaitMsg(message.getContent());
+        if (!ok) {
+            log.warn("微信待办推送失败: userId={}, title={}", message.getUserId(), message.getTitle());
+        }
+    }
+}

+ 144 - 55
cfc-backend/src/main/java/com/etotem/cfc/task/TaskCheckinWebhookReminderTask.java

@@ -2,42 +2,55 @@ package com.etotem.cfc.task;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.config.DatabaseInitializer;
+import com.etotem.cfc.dto.NotificationMessage;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskReminderConfig;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserNotificationMapper;
-import com.etotem.cfc.service.WechatWaitMsgService;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import com.etotem.cfc.service.notification.NotificationChannel;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 /**
- * 每日任务打卡提醒(webhook):提醒时间(任务 deadline)过 1 小时仍未打卡 → 微信待办消息推送。
+ * 任务打卡提醒调度器(可配置提醒模块)
  * <p>
- * 扫描范围:今日到期的「家长执行型」待打卡任务(executor_type=parent,status=pending),
- * deadline 已超过 now-60min(提醒时间 +1 小时宽限)且当天未推送过 → 调 WechatWaitMsgService 推送。
- * 幂等:以 user_notifications (type=task_checkin_webhook, ref_type=task, ref_id=任务ID, 当日) 判重,
- * 同一任务同一自然日最多推送一次;webhook 失败不写标记,下一分钟自动重试。
+ * 每分钟扫描「今日到期的家长执行型待打卡任务」,按用户提醒配置(task_reminder_config)触发:
+ * <ul>
+ *   <li>before_deadline:截止时间前 offset 分钟提醒"即将到期"</li>
+ *   <li>after_timeout:截止时间过后 offset 分钟仍未打卡提醒"已超时"</li>
+ * </ul>
+ * 渠道按配置多选分发(wechat=微信待办 webhook / sms=短信 / phone=语音 / email=邮件),
+ * 站内信(in_app)始终兜底写入并作幂等标记;未配置的用户走默认(微信+超时60分钟)。
+ * 同一任务同一自然日只提醒一次(兼容旧 type=task_checkin_webhook 记录,避免重复推送)。
  */
 @Slf4j
 @Component
 public class TaskCheckinWebhookReminderTask {
 
-    /** 宽限分钟数:提醒时间(deadline)过后多久未打卡才推送 */
-    private static final long GRACE_MINUTES = 60L;
+    /** 站内信/幂等标记通知类型(旧版为 task_checkin_webhook,一并纳入判重) */
+    private static final String NOTIFY_TYPE = "task_checkin_remind";
+    private static final String LEGACY_NOTIFY_TYPE = "task_checkin_webhook";
+    private static final String REF_TYPE = "task";
 
-    /** 单次调度最多处理条数,避免选数异常时形成消息轰炸 */
-    private static final int MAX_PROCESS_PER_RUN = 20;
+    /** 默认配置:微信 + 超时后60分钟(与线上行为一致) */
+    private static final String DEFAULT_CHANNELS = "wechat";
+    private static final String DEFAULT_TIMING = "after_timeout";
+    private static final int DEFAULT_OFFSET_MINUTES = 60;
 
-    private static final String NOTIFY_TYPE = "task_checkin_webhook";
-    private static final String REF_TYPE = "task";
+    /** 单次调度最多处理条数,避免异常时消息轰炸 */
+    private static final int MAX_PROCESS_PER_RUN = 200;
 
     @Resource
     private TaskMapper taskMapper;
@@ -49,7 +62,10 @@ public class TaskCheckinWebhookReminderTask {
     private UserNotificationMapper userNotificationMapper;
 
     @Resource
-    private WechatWaitMsgService wechatWaitMsgService;
+    private TaskReminderConfigService taskReminderConfigService;
+
+    @Resource
+    private List<NotificationChannel> notificationChannels;
 
     /** 每分钟执行一次 */
     @Scheduled(cron = "0 * * * * ?")
@@ -60,54 +76,113 @@ public class TaskCheckinWebhookReminderTask {
         }
         try {
             Date now = new Date();
-            Calendar cal = Calendar.getInstance();
-            cal.set(Calendar.HOUR_OF_DAY, 0);
-            cal.set(Calendar.MINUTE, 0);
-            cal.set(Calendar.SECOND, 0);
-            cal.set(Calendar.MILLISECOND, 0);
-            Date dayStart = cal.getTime();
+            Date dayStart = startOfToday();
 
-            Date threshold = new Date(now.getTime() - GRACE_MINUTES * 60_000L);
+            Map<Long, TaskReminderConfig> configs = taskReminderConfigService.loadAllConfigs();
 
-            // 今日到期 + 已过提醒时间1小时 + 仍未打卡(pending)的家长执行型任务
             List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
                     .eq(Task::getIsTemplate, 0)
                     .eq(Task::getExecutorType, "parent")
                     .eq(Task::getStatus, "pending")
                     .ge(Task::getDeadline, dayStart)
-                    .lt(Task::getDeadline, threshold)
+                    .lt(Task::getDeadline, endOfToday())
                     .orderByAsc(Task::getDeadline)
                     .last("LIMIT " + MAX_PROCESS_PER_RUN));
             if (tasks == null || tasks.isEmpty()) {
                 return;
             }
 
-            int sent = 0;
+            int reminded = 0;
             for (Task task : tasks) {
                 try {
                     if (task.getExecutorId() == null) {
                         continue;
                     }
-                    if (alreadySentToday(task.getExecutorId(), task.getId(), dayStart)) {
+                    Long userId = task.getExecutorId();
+                    TaskReminderConfig config = configs.getOrDefault(userId, null);
+                    if (config != null && Integer.valueOf(0).equals(config.getEnabled())) {
+                        continue;
+                    }
+
+                    String timing = config != null ? config.getTimingMode() : DEFAULT_TIMING;
+                    int offset = config != null && config.getOffsetMinutes() != null
+                            ? config.getOffsetMinutes() : DEFAULT_OFFSET_MINUTES;
+                    String channels = config != null && config.getChannels() != null && !config.getChannels().isEmpty()
+                            ? "in_app," + config.getChannels() : "in_app," + DEFAULT_CHANNELS;
+
+                    Date deadline = task.getDeadline();
+                    if (deadline == null) {
+                        continue;
+                    }
+                    String reminderPhase = null;
+                    if ("before_deadline".equals(timing)) {
+                        // 截止前 offset 分钟提醒(已过截止时间则不再做提前提醒)
+                        if (deadline.after(now) && !deadline.after(new Date(now.getTime() + offset * 60_000L))) {
+                            reminderPhase = "before";
+                        }
+                    } else {
+                        // 超时 offset 分钟后提醒(今日到期、仍未打卡)
+                        if (!deadline.after(new Date(now.getTime() - offset * 60_000L))) {
+                            reminderPhase = "after";
+                        }
+                    }
+                    if (reminderPhase == null) {
                         continue;
                     }
-                    boolean ok = wechatWaitMsgService.sendWaitMsg(buildContent(task));
-                    if (ok) {
-                        markSent(task.getExecutorId(), task, now);
-                        sent++;
+                    if (alreadySentToday(userId, task.getId(), dayStart)) {
+                        continue;
                     }
+
+                    String content = buildContent(task, reminderPhase, offset, timing);
+                    NotificationMessage msg = new NotificationMessage(userId, NOTIFY_TYPE,
+                            "任务打卡提醒", content, REF_TYPE, task.getId(), channels);
+                    dispatch(msg);
+                    reminded++;
                 } catch (Exception e) {
-                    log.warn("打卡提醒推送失败 taskId={}, error={}", task.getId(), e.getMessage());
+                    log.warn("打卡提醒失败 taskId={}, error={}", task.getId(), e.getMessage());
                 }
             }
-            log.info("打卡提醒完成:候选{}条,推送成功{}条", tasks.size(), sent);
+            log.info("打卡提醒完成:候选{}条,实际提醒{}条", tasks.size(), reminded);
         } catch (Exception e) {
             log.warn("打卡提醒任务异常: {}", e.getMessage());
         }
     }
 
-    /** 生成推送内容:问候用户昵称 + 任务标题(含说明摘要) */
-    private String buildContent(Task task) {
+    /** 按 channels 分发:in_app 恒在首位兜底;外部通道未启用/未接通时跳过并不阻断 */
+    private void dispatch(NotificationMessage msg) {
+        List<String> targets = new ArrayList<>();
+        for (String ch : msg.getChannels().split(",")) {
+            String c = ch.trim();
+            if (!c.isEmpty() && !targets.contains(c)) {
+                targets.add(c);
+            }
+        }
+        for (String target : targets) {
+            NotificationChannel matched = null;
+            for (NotificationChannel channel : notificationChannels) {
+                if (target.equals(channel.getChannelType())) {
+                    matched = channel;
+                    break;
+                }
+            }
+            if (matched == null) {
+                log.warn("渠道不存在: {},跳过", target);
+                continue;
+            }
+            if (!matched.isEnabled()) {
+                log.info("渠道未启用: {},跳过", target);
+                continue;
+            }
+            try {
+                matched.send(msg);
+            } catch (Exception e) {
+                log.warn("渠道发送异常: {} userId={}, error={}", target, msg.getUserId(), e.getMessage());
+            }
+        }
+    }
+
+    /** 组装提醒文案(before=即将到期 / after=已超时) */
+    private String buildContent(Task task, String phase, int offsetMinutes, String timing) {
         String name = "您";
         try {
             if (task.getExecutorId() != null) {
@@ -117,44 +192,58 @@ public class TaskCheckinWebhookReminderTask {
                 }
             }
         } catch (Exception e) {
-            // 昵称查询失败不阻塞推送
+            // 昵称查询失败不阻塞提醒
         }
         StringBuilder sb = new StringBuilder();
-        sb.append("「").append(name).append("」,您的健康任务已超过提醒时间 1 小时还没打卡:")
-                .append(task.getTitle());
-        if (task.getDescription() != null && !task.getDescription().isEmpty()) {
-            String desc = task.getDescription();
-            if (desc.length() > 60) {
-                desc = desc.substring(0, 60) + "…";
+        sb.append("「").append(name).append("」,您的健康任务「").append(task.getTitle()).append("」");
+        if ("before".equals(phase)) {
+            sb.append("将于").append(formatTime(task.getDeadline())).append("前截止,记得按时完成打卡哦~");
+        } else {
+            sb.append("已超过截止时间 ").append(offsetMinutes).append(" 分钟还没打卡");
+            if (task.getDescription() != null && !task.getDescription().isEmpty()) {
+                String desc = task.getDescription();
+                if (desc.length() > 40) {
+                    desc = desc.substring(0, 40) + "…";
+                }
+                sb.append("(").append(desc).append(")");
             }
-            sb.append("(").append(desc).append(")");
+            sb.append("。记得及时完成哦~");
         }
-        sb.append("。记得及时完成打卡哦~");
         return sb.toString();
     }
 
-    /** 同一任务同一自然日只推送一次 */
+    private String formatTime(Date date) {
+        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("HH:mm");
+        return sdf.format(date);
+    }
+
+    /** 同一任务同一自然日只提醒一次(含旧版 webhook 类型,避免升级后当天重复推送) */
     private boolean alreadySentToday(Long userId, Long taskId, Date dayStart) {
         Long cnt = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotification>()
                 .eq(UserNotification::getUserId, userId)
-                .eq(UserNotification::getType, NOTIFY_TYPE)
+                .in(UserNotification::getType, NOTIFY_TYPE, LEGACY_NOTIFY_TYPE)
                 .eq(UserNotification::getRefType, REF_TYPE)
                 .eq(UserNotification::getRefId, taskId)
                 .ge(UserNotification::getCreatedAt, dayStart));
         return cnt != null && cnt > 0;
     }
 
-    /** 推送成功落标记(同时作为站内通知展示给用户) */
-    private void markSent(Long userId, Task task, Date now) {
-        UserNotification n = new UserNotification();
-        n.setUserId(userId);
-        n.setType(NOTIFY_TYPE);
-        n.setTitle("任务打卡提醒");
-        n.setContent("「" + task.getTitle() + "」已超过提醒时间 1 小时未打卡,记得完成哦~");
-        n.setRefType(REF_TYPE);
-        n.setRefId(task.getId());
-        n.setIsRead(0);
-        n.setCreatedAt(now);
-        userNotificationMapper.insert(n);
+    private Date startOfToday() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
+    private Date endOfToday() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        cal.add(Calendar.DAY_OF_MONTH, 1);
+        return cal.getTime();
     }
 }

+ 13 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6076,3 +6076,16 @@ CREATE TABLE IF NOT EXISTS recommendation_product_associations (
     INDEX idx_type_enabled (association_type, enabled),
     INDEX idx_dimension (dimension_code)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐关联关系表';
+
+-- 迁移353: 任务提醒配置表(用户级,一行/用户)
+CREATE TABLE IF NOT EXISTS task_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT NOT NULL COMMENT '用户ID(users.id),家长账号',
+    channels VARCHAR(100) DEFAULT 'wechat' COMMENT '提醒渠道(逗号分隔): in_app/wechat/sms/phone/email',
+    timing_mode VARCHAR(20) DEFAULT 'after_timeout' COMMENT '提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒',
+    offset_minutes INT DEFAULT 60 COMMENT '偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟',
+    enabled TINYINT DEFAULT 1 COMMENT '是否启用 1=启用 0=停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表';

+ 221 - 0
cfc-backend/src/test/java/com/etotem/cfc/task/TaskCheckinWebhookReminderTaskTest.java

@@ -0,0 +1,221 @@
+package com.etotem.cfc.task;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.etotem.cfc.config.DatabaseInitializer;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskReminderConfig;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import com.etotem.cfc.service.notification.NotificationChannel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class TaskCheckinWebhookReminderTaskTest {
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private UserNotificationMapper userNotificationMapper;
+
+    @Mock
+    private TaskReminderConfigService taskReminderConfigService;
+
+    @Mock
+    private NotificationChannel inAppChannel;
+
+    @Mock
+    private NotificationChannel wechatChannel;
+
+    @Mock
+    private NotificationChannel smsChannel;
+
+    @InjectMocks
+    private TaskCheckinWebhookReminderTask task;
+
+    @BeforeEach
+    public void setUp() {
+        ReflectionTestUtils.setField(DatabaseInitializer.class, "migrationsComplete", true);
+        when(inAppChannel.getChannelType()).thenReturn("in_app");
+        when(inAppChannel.isEnabled()).thenReturn(true);
+        when(wechatChannel.getChannelType()).thenReturn("wechat");
+        when(wechatChannel.isEnabled()).thenReturn(true);
+        when(smsChannel.getChannelType()).thenReturn("sms");
+        when(smsChannel.isEnabled()).thenReturn(false);
+        ReflectionTestUtils.setField(task, "notificationChannels",
+                Arrays.asList(inAppChannel, wechatChannel, smsChannel));
+        when(userNotificationMapper.selectCount(any())).thenReturn(0L);
+
+        User user = new User();
+        user.setNickname("用户1830");
+        when(userMapper.selectById(eq(54L))).thenReturn(user);
+    }
+
+    private Task task(long id, Date deadline) {
+        Task t = new Task();
+        t.setId(id);
+        t.setIsTemplate(0);
+        t.setExecutorType("parent");
+        t.setExecutorId(54L);
+        t.setStatus("pending");
+        t.setDeadline(deadline);
+        t.setTitle("晨起空腹喝水");
+        t.setDescription("还原水 300-500ml");
+        return t;
+    }
+
+    private TaskReminderConfig config(String channels, String timing, int offset, int enabled) {
+        TaskReminderConfig c = new TaskReminderConfig();
+        c.setUserId(54L);
+        c.setChannels(channels);
+        c.setTimingMode(timing);
+        c.setOffsetMinutes(offset);
+        c.setEnabled(enabled);
+        return c;
+    }
+
+    @Test
+    public void 无配置用户默认超时60分钟_命中已超时任务_微信站内发送() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() - 90 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(1L, deadline));
+        }});
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(new HashMap<>());
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, times(1)).send(any());
+        verify(inAppChannel, times(1)).send(any());
+        verify(userNotificationMapper, times(1)).selectCount(any());
+    }
+
+    @Test
+    public void before窗口_截止前5分钟命中_提前提醒文案() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() + 5 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(2L, deadline));
+        }});
+        Map<Long, TaskReminderConfig> configs = new HashMap<>();
+        configs.put(54L, config("wechat", "before_deadline", 60, 1));
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(configs);
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, times(1)).send(any());
+        verify(inAppChannel, times(1)).send(any());
+    }
+
+    @Test
+    public void before窗口_截止前90分钟_不命中() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() + 90 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(3L, deadline));
+        }});
+        Map<Long, TaskReminderConfig> configs = new HashMap<>();
+        configs.put(54L, config("wechat", "before_deadline", 60, 1));
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(configs);
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, never()).send(any());
+        verify(inAppChannel, never()).send(any());
+    }
+
+    @Test
+    public void after窗口_超时边界_正好60分钟命中_59分钟不命中() {
+        Date now = new Date();
+        Task hit = task(4L, new Date(now.getTime() - 60 * 60_000L));
+        Task miss = task(5L, new Date(now.getTime() - 59 * 60_000L));
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(hit);
+            add(miss);
+        }});
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(new HashMap<>());
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, times(1)).send(any());
+        verify(inAppChannel, times(1)).send(any());
+    }
+
+    @Test
+    public void 用户停用提醒_不发送() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() - 90 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(6L, deadline));
+        }});
+        Map<Long, TaskReminderConfig> configs = new HashMap<>();
+        configs.put(54L, config("wechat", "after_timeout", 60, 0));
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(configs);
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, never()).send(any());
+        verify(inAppChannel, never()).send(any());
+    }
+
+    @Test
+    public void 今日已提醒过_跳过() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() - 90 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(7L, deadline));
+        }});
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(new HashMap<>());
+        when(userNotificationMapper.selectCount(any())).thenReturn(1L);
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, never()).send(any());
+        verify(inAppChannel, never()).send(any());
+    }
+
+    @Test
+    public void 多渠道_未启用渠道跳过_不影响启用渠道() {
+        Date now = new Date();
+        Date deadline = new Date(now.getTime() - 90 * 60_000L);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<Task>() {{
+            add(task(8L, deadline));
+        }});
+        Map<Long, TaskReminderConfig> configs = new HashMap<>();
+        configs.put(54L, config("wechat,sms", "after_timeout", 60, 1));
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(configs);
+
+        task.remindUncheckedTasks();
+
+        verify(wechatChannel, times(1)).send(any());
+        verify(inAppChannel, times(1)).send(any());
+        verify(smsChannel, never()).send(any());
+    }
+}