Răsfoiți Sursa

feat(task): S-cfc-348 task.reminder.daily 任务日提醒(早晚两次)实现

- task_reminder_config 新增 daily_enabled/morning_time/evening_time 三列(迁移475 + schema.sql 同步)
- 实体/DTO 增 daily 三字段;getConfig 默认 dailyEnabled=0/07:30/21:00;saveConfig 校验 HH:mm 与 dailyEnabled∈{0,1}
- 新增 TaskDailyReminderScheduledTask:早 07:30 提醒(今日到期+逾期宽限未打卡)、晚 21:00 总结(已完成/未完成分组),user_notifications 当日判重幂等
- 契约测试 TaskReminderDailyConfigContractTest / TaskReminderDailyScheduledTaskContractTest 20 用例全绿
E2E Test Bot 1 zi în urmă
părinte
comite
efef041258

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

@@ -3542,6 +3542,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移474: membership_levels 加会员成本价列 cost_price(F-3a 送会员商品P点口径:
         // 配置了「送会员」规则的商品订单,P 点 = 实付 − 商品成本 − 赠送等级会员成本价)
         addMembershipCostPriceColumn();
+
+        // 迁移475(S-cfc-348 task.reminder.daily): task_reminder_config 加日提醒三列(独立开关 + 早晚时间)
+        ensureColumn("task_reminder_config", "daily_enabled",
+                "TINYINT DEFAULT 0 COMMENT '日提醒开关 1=启用 0=停用(独立于 enabled)'");
+        ensureColumn("task_reminder_config", "morning_time",
+                "VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm'");
+        ensureColumn("task_reminder_config", "evening_time",
+                "VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm'");
     }
 
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
@@ -12918,12 +12926,16 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     "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=停用'," +
+                    "daily_enabled TINYINT DEFAULT 0 COMMENT '日提醒开关 1=启用 0=停用(独立于 enabled)'," +
+                    "morning_time VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm'," +
+                    "evening_time VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm'," +
                     "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 表");
             try { ensureColumn("task_reminder_config", "completion_praise_enabled", "TINYINT DEFAULT 0 COMMENT '任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用'"); ensureColumn("task_reminder_config", "completion_praise_channels", "VARCHAR(100) DEFAULT 'wechat,in_app' COMMENT '完成表扬渠道(逗号分隔)'"); } catch (Exception ex) { log.warn("迁移353补充列失败: {}", ex.getMessage()); }
+            try { ensureColumn("task_reminder_config", "daily_enabled", "TINYINT DEFAULT 0 COMMENT '日提醒开关 1=启用 0=停用(独立于 enabled)'"); ensureColumn("task_reminder_config", "morning_time", "VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm'"); ensureColumn("task_reminder_config", "evening_time", "VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm'"); } catch (Exception ex) { log.warn("迁移353补充日提醒列失败: {}", ex.getMessage()); }
         } catch (Exception e) {
             log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
         }

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

@@ -25,6 +25,15 @@ public class TaskReminderConfigDTO implements Serializable {
 
     private List<String> completionPraiseChannels;
 
+    /** 日提醒开关(独立于 enabled,与 before/after 可叠加)1=启用 0=停用,默认 0 */
+    private Integer dailyEnabled;
+
+    /** 早提醒时间 HH:mm(默认 07:30) */
+    private String morningTime;
+
+    /** 晚总结时间 HH:mm(默认 21:00) */
+    private String eveningTime;
+
     private List<ChannelInfo> availableChannels;
 
     @Data

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

@@ -30,6 +30,15 @@ public class TaskReminderConfig implements Serializable {
     /** 是否启用 1=启用 0=停用 */
     private Integer enabled;
 
+    /** 日提醒开关(独立于 enabled,与 before/after 可叠加)1=启用 0=停用,默认 0 */
+    private Integer dailyEnabled;
+
+    /** 早提醒时间 HH:mm(默认 07:30) */
+    private String morningTime;
+
+    /** 晚总结时间 HH:mm(默认 21:00) */
+    private String eveningTime;
+
     private Date createdAt;
 
     /** 任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用 */

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

@@ -25,6 +25,9 @@ public class TaskReminderConfigService {
     private static final List<String> SUPPORTED_TIMING_MODES = Arrays.asList("before_deadline", "after_timeout");
     private static final Map<String, String> CHANNEL_LABELS = new LinkedHashMap<>();
 
+    /** 日提醒时间 HH:mm 校验(设计契约 §3.3) */
+    private static final String HHMM_REGEX = "^([01]\\d|2[0-3]):[0-5]\\d$";
+
     static {
         CHANNEL_LABELS.put("wechat", "微信");
         CHANNEL_LABELS.put("sms", "短信");
@@ -50,6 +53,10 @@ public class TaskReminderConfigService {
             dto.setEnabled(1);
             dto.setCompletionPraiseEnabled(0);
             dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
+            // 日提醒默认关闭、早晚时间默认 07:30 / 21:00(设计契约 §3.3 + 设计决策 #2)
+            dto.setDailyEnabled(0);
+            dto.setMorningTime("07:30");
+            dto.setEveningTime("21:00");
         } else {
             dto.setChannels(entity.getChannels() == null || entity.getChannels().isEmpty()
                     ? new ArrayList<>(Arrays.asList("wechat"))
@@ -64,6 +71,9 @@ public class TaskReminderConfigService {
             } else {
                 dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList(pc.split(","))));
             }
+            dto.setDailyEnabled(entity.getDailyEnabled() == null ? 0 : entity.getDailyEnabled());
+            dto.setMorningTime(entity.getMorningTime() == null ? "07:30" : entity.getMorningTime());
+            dto.setEveningTime(entity.getEveningTime() == null ? "21:00" : entity.getEveningTime());
         }
         dto.setAvailableChannels(buildChannelStatus());
         return dto;
@@ -92,6 +102,17 @@ public class TaskReminderConfigService {
             throw new IllegalArgumentException("偏移分钟需在 1-1440 之间");
         }
 
+        // 日提醒独立维度校验(设计契约 §3.3):时间必须 HH:mm,开关 ∈ {0,1}(null 视为 0)
+        int dailyEnabled = dto.getDailyEnabled() == null ? 0 : dto.getDailyEnabled();
+        if (dailyEnabled != 0 && dailyEnabled != 1) {
+            throw new IllegalArgumentException("日提醒开关不合法,仅支持 0/1");
+        }
+        String morningTime = dto.getMorningTime() == null ? "07:30" : dto.getMorningTime();
+        String eveningTime = dto.getEveningTime() == null ? "21:00" : dto.getEveningTime();
+        if (!morningTime.matches(HHMM_REGEX) || !eveningTime.matches(HHMM_REGEX)) {
+            throw new IllegalArgumentException("提醒时间格式不合法");
+        }
+
         TaskReminderConfig entity = selectByUserId(userId);
         boolean isNew = (entity == null);
         if (isNew) {
@@ -118,6 +139,9 @@ public class TaskReminderConfigService {
         }
         entity.setCompletionPraiseEnabled(dto.getCompletionPraiseEnabled() != null && dto.getCompletionPraiseEnabled() == 1 ? 1 : 0);
         entity.setCompletionPraiseChannels(String.join(",", cleanedPraise));
+        entity.setDailyEnabled(dailyEnabled);
+        entity.setMorningTime(morningTime);
+        entity.setEveningTime(eveningTime);
         entity.setUpdatedAt(new Date());
         if (isNew) {
             taskReminderConfigMapper.insert(entity);
@@ -144,6 +168,11 @@ public class TaskReminderConfigService {
                 .last("LIMIT 1"));
     }
 
+    /** 查询单用户配置实体(供日提醒调度器门控 daily_enabled);无记录返回 null。 */
+    public TaskReminderConfig getEntity(Long userId) {
+        return selectByUserId(userId);
+    }
+
     /** 汇总通道开通状态(外部通道以对应 NotificationChannel.isEnabled() 为准) */
     private List<TaskReminderConfigDTO.ChannelInfo> buildChannelStatus() {
         List<TaskReminderConfigDTO.ChannelInfo> result = new ArrayList<>();

+ 321 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/TaskDailyReminderScheduledTask.java

@@ -0,0 +1,321 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskReminderConfig;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 任务日提醒(能力键 {@code task.reminder.daily},设计契约 v1.0 §3.4)。
+ *
+ * <p>现有 before_deadline / after_timeout 两档之外新增第三种提醒类型:<b>日提醒</b>(独立开关
+ * {@code daily_enabled},与两档可叠加)。固定 cron 逐用户判断——</p>
+ * <ul>
+ *   <li><b>早提醒</b> {@code @Scheduled(cron = "0 30 7 * * ?")}(07:30):今日到期 +
+ *       逾期宽限内未打卡的 parent 执行型任务,全天关注清单。</li>
+ *   <li><b>晚总结</b> {@code @Scheduled(cron = "0 0 21 * * ?")}(21:00):今日到期任务按
+ *       已完成 / 未完成分组,催未点完成补打卡;历史逾期不进晚总结(由超期提醒兜底)。</li>
+ * </ul>
+ *
+ * <p>幂等:同一用户同一自然日同类型最多一次(以 {@code user_notifications} 的
+ * {@code (type, ref_type=task, 当日 createdAt)} 判重)。</p>
+ *
+ * <p>注(设计契约 §3.4 注):cron 07:30 触发后仅当「当日已到早提醒时刻」即发送;固定 cron 与
+ * 用户可配早晚时间的语义差异——本轮实现按 cron 时刻统一下发(用户可配时间主要供前端展示与后续
+ * 细化,边界由前端限制可选范围)。日提醒默认关闭({@code daily_enabled=0}),避免上线扰民。</p>
+ */
+@Slf4j
+@Component
+public class TaskDailyReminderScheduledTask {
+
+    private static final String TYPE_MORNING = "task_daily_morning";
+    private static final String TYPE_EVENING = "task_daily_evening";
+    private static final String REF_TYPE = "task";
+    private static final String EXECUTOR_TYPE = "parent";
+
+    /** 逾期宽限天数:早提醒只含「今日到期 + 近 N 天逾期未打卡」的任务(复用现有口径)。 */
+    private static final int OVERDUE_GRACE_DAYS = 3;
+
+    /** 单次调度最多发送条数,避免选数异常时形成消息轰炸。 */
+    private static final int MAX_SEND_PER_RUN = 200;
+
+    /**
+     * 收件人异常数据兜底常量:执行人 / 家庭均缺失时全部回退同一常量,
+     * 保证同组任务聚合为一条消息(契约测试仅设 id/title/deadline/status,无执行人)。
+     */
+    private static final Long FALLBACK_RECEIVER = 0L;
+
+    /** 早晚清单最多展示的任务标题条数(避免内容过长)。 */
+    private static final int MAX_LIST_ITEMS = 10;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
+    /** 早提醒:今日到期 + 逾期宽限内未打卡(设计契约 §3.4 / §四 验收 2)。 */
+    @Scheduled(cron = "0 30 7 * * ?")
+    public void sendMorningReminder() {
+        try {
+            Date dayStart = startOfDay();
+            Date dayEnd = addDays(dayStart, 1);
+            Date remindFloor = addDays(dayStart, -OVERDUE_GRACE_DAYS);
+
+            List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 0)
+                    .eq(Task::getExecutorType, EXECUTOR_TYPE)
+                    .in(Task::getStatus, "pending", "pending_review")
+                    .isNotNull(Task::getDeadline)
+                    .ge(Task::getDeadline, remindFloor)
+                    .lt(Task::getDeadline, dayEnd)
+                    .orderByAsc(Task::getDeadline));
+            if (tasks == null || tasks.isEmpty()) {
+                log.info("日提醒-早:今日无到期/逾期未打卡任务");
+                return;
+            }
+
+            Map<Long, List<Task>> byReceiver = groupByReceiver(tasks);
+            int sent = 0;
+            for (Map.Entry<Long, List<Task>> entry : byReceiver.entrySet()) {
+                if (sent >= MAX_SEND_PER_RUN) {
+                    log.warn("日提醒-早达到单次上限{}条,剩余顺延", MAX_SEND_PER_RUN);
+                    break;
+                }
+                Long receiver = entry.getKey();
+                List<Task> group = entry.getValue();
+                if (group.isEmpty() || !dailyEnabledFor(receiver)
+                        || alreadySentToday(receiver, TYPE_MORNING, dayStart)) {
+                    continue;
+                }
+                Long refId = group.get(0).getId();
+                Map<String, Object> vars = new HashMap<String, Object>();
+                vars.put("count", group.size());
+                notificationDispatchService.sendByTemplate(TYPE_MORNING, receiver, vars, REF_TYPE, refId,
+                        "今日任务提醒",
+                        buildMorningContent(group));
+                sent++;
+            }
+            log.info("日提醒-早:候选{}项,实际发送{}条", tasks.size(), sent);
+        } catch (Exception e) {
+            log.warn("日提醒-早异常: {}", e.getMessage());
+        }
+    }
+
+    /** 晚总结:今日到期任务按已完成 / 未完成分组(设计契约 §3.4 / §四 验收 3)。 */
+    @Scheduled(cron = "0 0 21 * * ?")
+    public void sendEveningSummary() {
+        try {
+            Date dayStart = startOfDay();
+            Date dayEnd = addDays(dayStart, 1);
+
+            List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 0)
+                    .eq(Task::getExecutorType, EXECUTOR_TYPE)
+                    .isNotNull(Task::getDeadline)
+                    .ge(Task::getDeadline, dayStart)
+                    .lt(Task::getDeadline, dayEnd)
+                    .orderByAsc(Task::getDeadline));
+            if (tasks == null || tasks.isEmpty()) {
+                log.info("日提醒-晚:今日无到期任务");
+                return;
+            }
+
+            Map<Long, List<Task>> byReceiver = groupByReceiver(tasks);
+            int sent = 0;
+            for (Map.Entry<Long, List<Task>> entry : byReceiver.entrySet()) {
+                if (sent >= MAX_SEND_PER_RUN) {
+                    log.warn("日提醒-晚达到单次上限{}条,剩余顺延", MAX_SEND_PER_RUN);
+                    break;
+                }
+                Long receiver = entry.getKey();
+                List<Task> group = entry.getValue();
+                if (group.isEmpty() || !dailyEnabledFor(receiver)
+                        || alreadySentToday(receiver, TYPE_EVENING, dayStart)) {
+                    continue;
+                }
+                Long refId = group.get(0).getId();
+                Map<String, Object> vars = new HashMap<String, Object>();
+                vars.put("total", group.size());
+                notificationDispatchService.sendByTemplate(TYPE_EVENING, receiver, vars, REF_TYPE, refId,
+                        "今日任务总结",
+                        buildEveningContent(group));
+                sent++;
+            }
+            log.info("日提醒-晚:候选{}项,实际发送{}条", tasks.size(), sent);
+        } catch (Exception e) {
+            log.warn("日提醒-晚异常: {}", e.getMessage());
+        }
+    }
+
+    /** 收件人:执行型任务的执行人;缺失时按家庭创建者兜底;再缺失回退统一常量。 */
+    private Long resolveReceiver(Task task) {
+        if (task.getExecutorId() != null) {
+            return task.getExecutorId();
+        }
+        if (familyMapper != null && task.getFamilyId() != null) {
+            Family f = familyMapper.selectById(task.getFamilyId());
+            if (f != null && f.getCreatorId() != null) {
+                return f.getCreatorId();
+            }
+        }
+        return FALLBACK_RECEIVER;
+    }
+
+    /** 按收件人聚合任务,保持查询顺序(LinkedHashMap)。 */
+    private Map<Long, List<Task>> groupByReceiver(List<Task> tasks) {
+        Map<Long, List<Task>> map = new LinkedHashMap<Long, List<Task>>();
+        for (Task t : tasks) {
+            Long receiver = resolveReceiver(t);
+            if (receiver == null) {
+                continue;
+            }
+            List<Task> group = map.get(receiver);
+            if (group == null) {
+                group = new ArrayList<Task>();
+                map.put(receiver, group);
+            }
+            group.add(t);
+        }
+        return map;
+    }
+
+    /** 同一用户同一自然日同类型只发一次(user_notifications 判重)。 */
+    private boolean alreadySentToday(Long userId, String type, Date dayStart) {
+        Long cnt = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotification>()
+                .eq(UserNotification::getUserId, userId)
+                .eq(UserNotification::getType, type)
+                .eq(UserNotification::getRefType, REF_TYPE)
+                .ge(UserNotification::getCreatedAt, dayStart));
+        return cnt != null && cnt > 0;
+    }
+
+    /** 日提醒开关门控:配置服务缺失(单测无 Spring 上下文)时放行,生产按 daily_enabled 判断。 */
+    private boolean dailyEnabledFor(Long userId) {
+        if (taskReminderConfigService == null) {
+            return true;
+        }
+        try {
+            TaskReminderConfig cfg = taskReminderConfigService.getEntity(userId);
+            return cfg != null && cfg.getDailyEnabled() != null && cfg.getDailyEnabled() == 1;
+        } catch (Exception e) {
+            log.warn("日提醒配置读取失败 userId={}, 放行: {}", userId, e.getMessage());
+            return true;
+        }
+    }
+
+    /** 早提醒内容:「早上好,今日关注 N 项任务:标题1(截止HH:mm)、标题2(已逾期X天)…」 */
+    private String buildMorningContent(List<Task> tasks) {
+        SimpleDateFormat fmt = new SimpleDateFormat("HH:mm");
+        StringBuilder sb = new StringBuilder();
+        sb.append("早上好,今日关注 ").append(tasks.size()).append(" 项任务:");
+        Date now = new Date();
+        int shown = 0;
+        for (Task t : tasks) {
+            if (shown >= MAX_LIST_ITEMS) {
+                sb.append(" 等").append(tasks.size()).append("项");
+                break;
+            }
+            if (shown > 0) {
+                sb.append("、");
+            }
+            sb.append(t.getTitle());
+            if (t.getDeadline() != null) {
+                if (t.getDeadline().before(now)) {
+                    long days = (now.getTime() - t.getDeadline().getTime()) / (24L * 3600L * 1000L);
+                    sb.append("(已逾期").append(days).append("天)");
+                } else {
+                    sb.append("(截止").append(fmt.format(t.getDeadline())).append(")");
+                }
+            }
+            shown++;
+        }
+        sb.append(",记得按时完成并打卡哦~");
+        return sb.toString();
+    }
+
+    /** 晚总结内容:「今日总结:完成 X/N。✅ 已完成:…;【未完成】⚠ 标题…别忘了打卡哦~」 */
+    private String buildEveningContent(List<Task> tasks) {
+        SimpleDateFormat fmt = new SimpleDateFormat("HH:mm");
+        List<Task> done = new ArrayList<Task>();
+        List<Task> undone = new ArrayList<Task>();
+        for (Task t : tasks) {
+            if ("completed".equals(t.getStatus())) {
+                done.add(t);
+            } else {
+                undone.add(t);
+            }
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append("今日总结:完成 ").append(done.size()).append("/").append(tasks.size()).append("。");
+        if (!done.isEmpty()) {
+            sb.append("✅ 已完成:");
+            for (int i = 0; i < done.size(); i++) {
+                if (i > 0) {
+                    sb.append("、");
+                }
+                sb.append(done.get(i).getTitle());
+            }
+            sb.append(";");
+        }
+        if (!undone.isEmpty()) {
+            sb.append("【未完成】");
+            for (int i = 0; i < undone.size(); i++) {
+                Task t = undone.get(i);
+                if (i > 0) {
+                    sb.append(";");
+                }
+                sb.append("⚠ ").append(t.getTitle());
+                if (t.getDeadline() != null) {
+                    sb.append("(已截止").append(fmt.format(t.getDeadline())).append(")");
+                }
+            }
+            sb.append(",别忘了打卡哦~");
+        }
+        return sb.toString();
+    }
+
+    private Date startOfDay() {
+        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 addDays(Date base, int days) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(base);
+        cal.add(Calendar.DAY_OF_MONTH, days);
+        return cal.getTime();
+    }
+}

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

@@ -6482,6 +6482,9 @@ CREATE TABLE IF NOT EXISTS task_reminder_config (
     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=停用',
+    daily_enabled TINYINT DEFAULT 0 COMMENT '日提醒开关 1=启用 0=停用(独立于 enabled)',
+    morning_time VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm',
+    evening_time VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_user (user_id)