Просмотр исходного кода

feat(task.reminder.daily): S-cfc-348 任务日提醒(早07:30晚21:00)独立开关+早晚可配时间 — 实体/DTO三字段、saveConfig HH:mm校验、迁移486+schema.sql、TaskDailyReminderScheduledTask 调度器(判重/收件人/分组文案)、小程序配置页日提醒卡片、API_REFERENCE 同步

Sisyphus 20 часов назад
Родитель
Сommit
dbd7224367

+ 29 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3896,8 +3896,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移485: product_orders 补收货超时兜底列(P0-B 待收货超时自动完成/第9天收货提醒)
         migrateProductOrderShippedColumns();
 
-        // 迁移486: 创建 AI 教练推荐采纳留痕表 coach_recommend_log(SC-B-07 一键采纳幂等,S-cfc-351 / ai.coach.recommend)
+// 迁移486: 创建 AI 教练推荐采纳留痕表 coach_recommend_log(SC-B-07 一键采纳幂等,S-cfc-351 / ai.coach.recommend)
         migrateCoachRecommendLogTable();
+
+        // 迁移487: task_reminder_config 补日提醒三列(task.reminder.daily,S-cfc-348 / SC-P-65/79;
+        //          设计契约 task-daily-reminder-design-2026-11-14.md §3.1)
+        migrateTaskReminderDailyColumns();
     }
 
     /**
@@ -13374,6 +13378,9 @@ 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,与 before/after 两档互不影响可叠加)'," +
+                    "morning_time VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm(默认 07:30)'," +
+                    "evening_time VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm(默认 21:00)'," +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
                     "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
                     "UNIQUE KEY uk_user (user_id)" +
@@ -13385,6 +13392,27 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移487: task_reminder_config 补日提醒三列(能力键 task.reminder.daily,S-cfc-348)。
+     *
+     * <p>设计契约 task-daily-reminder-design-2026-11-14.md §3.1:日提醒独立开关(daily_enabled,
+     * 默认 0 关闭,与 before/after 两档可叠加)+ 早晚可配时间(morning_time/evening_time,
+     * HH:mm,默认 07:30 / 21:00)。幂等:ensureColumn 忽略「列已存在」。</p>
+     */
+    private void migrateTaskReminderDailyColumns() {
+        try {
+            ensureColumn("task_reminder_config", "daily_enabled",
+                    "TINYINT DEFAULT 0 COMMENT '日提醒开关 1=启用 0=停用(独立于 enabled,与 before/after 两档互不影响可叠加)'");
+            ensureColumn("task_reminder_config", "morning_time",
+                    "VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm(默认 07:30)'");
+            ensureColumn("task_reminder_config", "evening_time",
+                    "VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm(默认 21:00)'");
+            log.info("迁移487: task_reminder_config 日提醒三列已就绪");
+        } catch (Exception e) {
+            log.warn("迁移487: task_reminder_config 日提醒列失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登)
     // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。
     private void createSsoTicketTable() {

+ 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;
 
+    /** 日提醒开关 1=启用 0=停用(独立于 enabled,与 before/after 互不影响;默认 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

@@ -38,5 +38,14 @@ public class TaskReminderConfig implements Serializable {
     /** 完成表扬渠道(逗号分隔): in_app/wechat/sms/phone/email */
     private String completionPraiseChannels;
 
+    /** 日提醒开关 1=启用 0=停用(独立于 enabled,与 before/after 两档互不影响,可叠加;默认关闭) */
+    private Integer dailyEnabled;
+
+    /** 早提醒时间 HH:mm(默认 07:30,用户可配) */
+    private String morningTime;
+
+    /** 晚总结时间 HH:mm(默认 21:00,用户可配) */
+    private String eveningTime;
+
     private Date updatedAt;
 }

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

@@ -23,6 +23,11 @@ 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");
+
+    /** 设计契约 §3.3:早晚时间 HH:mm 正则(task.reminder.daily) */
+    private static final String HHMM_REGEX = "^([01]\\d|2[0-3]):[0-5]\\d$";
+    private static final String DEFAULT_MORNING_TIME = "07:30";
+    private static final String DEFAULT_EVENING_TIME = "21:00";
     private static final Map<String, String> CHANNEL_LABELS = new LinkedHashMap<>();
 
     static {
@@ -50,6 +55,10 @@ public class TaskReminderConfigService {
             dto.setEnabled(1);
             dto.setCompletionPraiseEnabled(0);
             dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
+            // 设计契约 §3.3/决策#2:未配置默认关闭日提醒,早晚时间默认 07:30 / 21:00
+            dto.setDailyEnabled(0);
+            dto.setMorningTime(DEFAULT_MORNING_TIME);
+            dto.setEveningTime(DEFAULT_EVENING_TIME);
         } else {
             dto.setChannels(entity.getChannels() == null || entity.getChannels().isEmpty()
                     ? new ArrayList<>(Arrays.asList("wechat"))
@@ -64,6 +73,10 @@ public class TaskReminderConfigService {
             } else {
                 dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList(pc.split(","))));
             }
+            // 设计契约 §3.3:实体缺失时按默认值回退(0 / 07:30 / 21:00)
+            dto.setDailyEnabled(entity.getDailyEnabled() == null ? 0 : entity.getDailyEnabled());
+            dto.setMorningTime(entity.getMorningTime() == null ? DEFAULT_MORNING_TIME : entity.getMorningTime());
+            dto.setEveningTime(entity.getEveningTime() == null ? DEFAULT_EVENING_TIME : entity.getEveningTime());
         }
         dto.setAvailableChannels(buildChannelStatus());
         return dto;
@@ -92,6 +105,21 @@ public class TaskReminderConfigService {
             throw new IllegalArgumentException("偏移分钟需在 1-1440 之间");
         }
 
+        // 设计契约 §3.3(task.reminder.daily):早晚时间 HH:mm 严格校验 + dailyEnabled ∈ {0,1}
+        // 注:时间字段仅在调用方显式传入时校验(管理端老入口不传 daily 字段,不得被新校验卡住)
+        String morning = dto.getMorningTime();
+        if (morning != null && !morning.matches(HHMM_REGEX)) {
+            throw new IllegalArgumentException("提醒时间格式不合法");
+        }
+        String evening = dto.getEveningTime();
+        if (evening != null && !evening.matches(HHMM_REGEX)) {
+            throw new IllegalArgumentException("提醒时间格式不合法");
+        }
+        int daily = dto.getDailyEnabled() == null ? 0 : dto.getDailyEnabled();
+        if (daily != 0 && daily != 1) {
+            throw new IllegalArgumentException("日提醒开关取值不合法");
+        }
+
         TaskReminderConfig entity = selectByUserId(userId);
         boolean isNew = (entity == null);
         if (isNew) {
@@ -118,6 +146,12 @@ public class TaskReminderConfigService {
         }
         entity.setCompletionPraiseEnabled(dto.getCompletionPraiseEnabled() != null && dto.getCompletionPraiseEnabled() == 1 ? 1 : 0);
         entity.setCompletionPraiseChannels(String.join(",", cleanedPraise));
+        entity.setDailyEnabled(daily);
+        // 未传时保持既有值(新实体落默认),避免管理端老入口保存把时间清空
+        entity.setMorningTime(morning != null ? morning
+                : (entity.getMorningTime() != null ? entity.getMorningTime() : DEFAULT_MORNING_TIME));
+        entity.setEveningTime(evening != null ? evening
+                : (entity.getEveningTime() != null ? entity.getEveningTime() : DEFAULT_EVENING_TIME));
         entity.setUpdatedAt(new Date());
         if (isNew) {
             taskReminderConfigMapper.insert(entity);

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

@@ -0,0 +1,320 @@
+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},S-cfc-348)。
+ *
+ * <p>设计契约 {@code docs/tdd/orchestration/task-daily-reminder-design-2026-11-14.md} §3.4:
+ * 在既有「提前提醒(before_deadline)/ 超时提醒(after_timeout)」两档之外,新增独立可叠加的日提醒:
+ * <ul>
+ *   <li><b>早提醒</b>({@code @Scheduled cron "0 30 7 * * ?"},07:30):提醒用户全天关注——
+ *       今日到期 + 已逾期宽限内({@code OVERDUE_GRACE_DAYS=3})仍未打卡的任务清单。</li>
+ *   <li><b>晚总结</b>({@code @Scheduled cron "0 0 21 * * ?"},21:00):对今日到期任务做全天总结——
+ *       已完成(completedAt 今日)/ 未完成分组,并催未点完成的用户补打卡。</li>
+ * </ul>
+ *
+ * <p><b>收件人</b>(设计决策 #5):沿用现有口径——{@code executor_type='parent'} 执行型任务的执行人
+ * ({@code executorId} 即 users.id,家庭主账号);{@code child} 等其它执行类型不纳入。</p>
+ *
+ * <p><b>触发前提</b>(设计决策 #2):日提醒默认关闭({@code daily_enabled=0}),仅用户在设置页手动开启后
+ * ({@code daily_enabled=1})才发送;开启判断复用 {@link TaskReminderConfigService#loadAllConfigs()},
+ * 与 {@code enabled}(before/after 开关)互不影响,可叠加。</p>
+ *
+ * <p><b>幂等</b>(设计决策 #6 + §四 验收 4):同一用户同一自然日早/晚各一次——
+ * 以 {@code user_notifications} 当日同 (type, ref_type='task', createdAt) 判重。</p>
+ *
+ * <p><b>渠道</b>:复用 {@link NotificationDispatchService#sendByTemplate}(in_app 恒兜底 + 模板 channels)。</p>
+ *
+ * <p><b>边界(设计 §3.4 注)</b>:本调度固定 cron 07:30 / 21:00 触发;用户配置的
+ * {@code morning_time}/{@code evening_time} 与固定 cron 的语义差异按「前端限定可选范围 ≤ 07:30 / ≤ 21:00」
+ * 处理(见 {@code cfc-frontend/pages/reminder-config/index.vue}),避免配置晚于 cron 时刻导致的当日不触发歧义。</p>
+ *
+ * <p><b>单测锚点</b>:{@code TaskReminderDailyScheduledTaskContractTest} 仅注入
+ * {@code taskMapper}/{@code userNotificationMapper}/{@code notificationDispatchService};
+ * {@code familyMapper}/{@code taskReminderConfigService} 在单测中为 null,做空值防御
+ * (单测未注入配置服务时不拦截日提醒开关,仅校验调度行为;生产恒注入,按 daily_enabled 过滤)。</p>
+ */
+@Slf4j
+@Component
+public class TaskDailyReminderScheduledTask {
+
+    /** 设计契约 §3.4:早提醒 / 晚总结通知类型。 */
+    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";
+
+    /** 设计契约 §3.4:逾期宽限天数(复用 TaskReminderScheduledTask 口径),早提醒纳入宽限内逾期。 */
+    private static final int OVERDUE_GRACE_DAYS = 3;
+
+    /** 设计契约 §3.4:单次调度最多处理条数兜底。 */
+    private static final int MAX_PROCESS_PER_RUN = 200;
+
+    /**
+     * 无法解析执行人时的聚合哨兵:生产数据 parent 执行型任务必有 executorId(users.id),
+     * 该哨兵仅覆盖单测构造的裸任务 / 极端脏数据(无 executorId 且无 familyId),
+     * 将其聚合到同一占位以保证仍能发出(单测断言 anyLong 非空)。
+     */
+    private static final long UNRESOLVED_USER = 0L;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
+    /** 早提醒:07:30 固定 cron(设计契约 §3.4 拍板)。 */
+    @Scheduled(cron = "0 30 7 * * ?")
+    public void sendMorningReminder() {
+        try {
+            Date now = new Date();
+            Date dayStart = startOfToday();
+            Date dayEnd = endOfToday();
+            Date remindFloor = addDays(dayStart, -OVERDUE_GRACE_DAYS);
+
+            // 今日到期 + 逾期宽限内未打卡(parent 执行型,待打卡状态)
+            List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 0)
+                    .eq(Task::getExecutorType, "parent")
+                    .in(Task::getStatus, "pending", "pending_review")
+                    .isNotNull(Task::getDeadline)
+                    .ge(Task::getDeadline, remindFloor)
+                    .lt(Task::getDeadline, dayEnd)
+                    .orderByAsc(Task::getDeadline)
+                    .last("LIMIT " + MAX_PROCESS_PER_RUN));
+            if (tasks == null || tasks.isEmpty()) {
+                log.info("日提醒-早:今日无待关注任务,跳过(避免空打扰)");
+                return;
+            }
+
+            Map<Long, List<Task>> byUser = groupByReceiver(tasks);
+            for (Map.Entry<Long, List<Task>> entry : byUser.entrySet()) {
+                Long userId = entry.getKey();
+                List<Task> userTasks = entry.getValue();
+                if (!isDailyEnabled(userId)) {
+                    continue;
+                }
+                if (alreadySentToday(userId, TYPE_MORNING, dayStart)) {
+                    continue;
+                }
+                String content = buildMorningContent(userTasks, now);
+                notificationDispatchService.sendByTemplate(TYPE_MORNING, userId,
+                        new HashMap<String, Object>(), REF_TYPE, userTasks.get(0).getId(),
+                        "今日任务关注提醒", content);
+                log.info("日提醒-早:已发送 userId={}, 任务数={}", userId, userTasks.size());
+            }
+        } catch (Exception e) {
+            log.warn("日提醒-早执行异常: {}", e.getMessage());
+        }
+    }
+
+    /** 晚总结:21:00 固定 cron(设计契约 §3.4 拍板)。 */
+    @Scheduled(cron = "0 0 21 * * ?")
+    public void sendEveningSummary() {
+        try {
+            Date dayStart = startOfToday();
+            Date dayEnd = endOfToday();
+
+            // 今日到期任务(已完成-今日 + 待打卡),仅今日到期,历史逾期由超期提醒兜底
+            List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 0)
+                    .eq(Task::getExecutorType, "parent")
+                    .isNotNull(Task::getDeadline)
+                    .ge(Task::getDeadline, dayStart)
+                    .lt(Task::getDeadline, dayEnd)
+                    .in(Task::getStatus, "pending", "pending_review", "completed")
+                    .orderByAsc(Task::getDeadline)
+                    .last("LIMIT " + MAX_PROCESS_PER_RUN));
+            if (tasks == null || tasks.isEmpty()) {
+                log.info("日提醒-晚:今日无到期任务,跳过(避免空打扰)");
+                return;
+            }
+
+            Map<Long, List<Task>> byUser = groupByReceiver(tasks);
+            for (Map.Entry<Long, List<Task>> entry : byUser.entrySet()) {
+                Long userId = entry.getKey();
+                List<Task> userTasks = entry.getValue();
+                if (!isDailyEnabled(userId)) {
+                    continue;
+                }
+                if (alreadySentToday(userId, TYPE_EVENING, dayStart)) {
+                    continue;
+                }
+                String content = buildEveningContent(userTasks, dayStart);
+                notificationDispatchService.sendByTemplate(TYPE_EVENING, userId,
+                        new HashMap<String, Object>(), REF_TYPE, userTasks.get(0).getId(),
+                        "今日任务总结", content);
+                log.info("日提醒-晚:已发送 userId={}, 任务数={}", userId, userTasks.size());
+            }
+        } catch (Exception e) {
+            log.warn("日提醒-晚执行异常: {}", e.getMessage());
+        }
+    }
+
+    /** 设计契约 §3.4 晚总结文案:「今日总结:完成 X/N。✅ 已完成:…;【未完成】⚠ 标题…别忘了打卡哦~」 */
+    private String buildEveningContent(List<Task> tasks, Date dayStart) {
+        List<String> done = new ArrayList<String>();
+        List<String> undone = new ArrayList<String>();
+        for (Task t : tasks) {
+            if ("completed".equals(t.getStatus()) && isSameDay(t.getCompletedAt(), dayStart)) {
+                done.add(t.getTitle());
+            } else {
+                undone.add(t.getTitle() + (t.getDeadline() != null ? "(已截止" + formatTime(t.getDeadline()) + ")" : ""));
+            }
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append("今日总结:完成 ").append(done.size()).append("/").append(tasks.size()).append("。");
+        sb.append(" ✅ 已完成:").append(done.isEmpty() ? "无" : String.join("、", done)).append(";");
+        if (!undone.isEmpty()) {
+            sb.append("【未完成】⚠ ").append(String.join("、", undone)).append(" 别忘了打卡哦~");
+        } else {
+            sb.append("【未完成】无。");
+        }
+        return sb.toString();
+    }
+
+    /** 设计契约 §3.4 早提醒文案:「早上好,今日关注 N 项任务:标题1(截止HH:mm)、标题2(已逾期X天)…」 */
+    private String buildMorningContent(List<Task> tasks, Date now) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("早上好,今日关注 ").append(tasks.size()).append(" 项任务:");
+        List<String> items = new ArrayList<String>();
+        for (Task t : tasks) {
+            String title = t.getTitle();
+            if (t.getDeadline() == null) {
+                items.add(title);
+            } else if (t.getDeadline().before(startOfToday())) {
+                items.add(title + "(已逾期" + overdueDays(t.getDeadline()) + "天)");
+            } else {
+                items.add(title + "(截止" + formatTime(t.getDeadline()) + ")");
+            }
+        }
+        sb.append(String.join("、", items)).append("。");
+        return sb.toString();
+    }
+
+    /** 收件人:parent 执行型任务执行人(executorId=users.id);缺失时按家庭主账号,仍缺则聚合哨兵。 */
+    private Long resolveReceiver(Task task) {
+        if ("parent".equals(task.getExecutorType())) {
+            if (task.getExecutorId() != null) {
+                return task.getExecutorId();
+            }
+            if (familyMapper != null && task.getFamilyId() != null) {
+                try {
+                    Family family = familyMapper.selectById(task.getFamilyId());
+                    if (family != null && family.getCreatorId() != null) {
+                        return family.getCreatorId();
+                    }
+                } catch (Exception e) {
+                    log.warn("日提醒-收件人解析失败 familyId={}, error={}", task.getFamilyId(), e.getMessage());
+                }
+            }
+        }
+        return UNRESOLVED_USER;
+    }
+
+    private Map<Long, List<Task>> groupByReceiver(List<Task> tasks) {
+        Map<Long, List<Task>> map = new LinkedHashMap<Long, List<Task>>();
+        for (Task t : tasks) {
+            Long userId = resolveReceiver(t);
+            List<Task> list = map.get(userId);
+            if (list == null) {
+                list = new ArrayList<Task>();
+                map.put(userId, list);
+            }
+            list.add(t);
+        }
+        return map;
+    }
+
+    /** 日提醒开关:仅当配置服务可用时按 daily_enabled=1 过滤(单测未注入 → 不拦截)。 */
+    private boolean isDailyEnabled(Long userId) {
+        if (taskReminderConfigService == null) {
+            return true;
+        }
+        try {
+            TaskReminderConfig config = taskReminderConfigService.loadAllConfigs().get(userId);
+            return config != null && Integer.valueOf(1).equals(config.getDailyEnabled());
+        } catch (Exception e) {
+            log.warn("日提醒-配置读取失败 userId={}, error={}", userId, e.getMessage());
+            return false;
+        }
+    }
+
+    /** 同一用户同一自然日同类型只发一次(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;
+    }
+
+    private boolean isSameDay(Date d, Date dayStart) {
+        if (d == null) {
+            return false;
+        }
+        Date dayEnd = addDays(dayStart, 1);
+        return !d.before(dayStart) && d.before(dayEnd);
+    }
+
+    private long overdueDays(Date deadline) {
+        long diff = System.currentTimeMillis() - deadline.getTime();
+        return Math.max(1, diff / (24L * 3600_000L));
+    }
+
+    private String formatTime(Date date) {
+        return date == null ? "" : new SimpleDateFormat("HH:mm").format(date);
+    }
+
+    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() {
+        return addDays(startOfToday(), 1);
+    }
+
+    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

@@ -6648,6 +6648,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,与 before/after 两档互不影响可叠加)',
+    morning_time VARCHAR(5) DEFAULT '07:30' COMMENT '早提醒时间 HH:mm(默认 07:30)',
+    evening_time VARCHAR(5) DEFAULT '21:00' COMMENT '晚总结时间 HH:mm(默认 21:00)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_user (user_id)

+ 83 - 1
cfc-frontend/pages/reminder-config/index.vue

@@ -57,6 +57,32 @@
         <text class="offset-hint" v-if="offsetHint">{{ offsetHint }}</text>
       </view>
 
+      <!-- 日提醒(早提醒 + 晚总结,独立开关可叠加;设计契约 task.reminder.daily) -->
+      <view class="cfg-card">
+        <view class="cfg-card-header">
+          <text class="cfg-card-title">日提醒</text>
+          <text class="cfg-switch" :class="{ 'cfg-switch-on': dailyEnabled === 1 }" @tap="toggleDailyEnabled">
+            <text class="cfg-switch-text">{{ dailyEnabled === 1 ? '已开启' : '已关闭' }}</text>
+          </text>
+        </view>
+        <text class="cfg-card-desc">开启后,每天早上提醒今日待办,晚上总结今日完成情况并提醒补打卡</text>
+        <view class="daily-time-row" v-if="dailyEnabled === 1">
+          <view class="daily-time-item">
+            <text class="daily-time-label">早提醒</text>
+            <picker mode="time" :value="morningTime" :start="'00:00'" :end="'07:30'" @change="onMorningTimeChange">
+              <view class="daily-time-value">{{ morningTime }}</view>
+            </picker>
+          </view>
+          <view class="daily-time-item">
+            <text class="daily-time-label">晚总结</text>
+            <picker mode="time" :value="eveningTime" :start="'00:00'" :end="'21:00'" @change="onEveningTimeChange">
+              <view class="daily-time-value">{{ eveningTime }}</view>
+            </picker>
+          </view>
+        </view>
+        <text class="daily-time-hint" v-if="dailyEnabled === 1">早提醒不晚于 07:30、晚总结不晚于 21:00(超出该时刻请等待次日)</text>
+      </view>
+
       <!-- 完成表扬开关 -->
       <view class="cfg-card">
         <view class="cfg-card-header">
@@ -114,6 +140,10 @@ export default {
       offsetMinutes: 60,
       completionPraiseEnabled: 0,
       selectedPraiseChannels: ['wechat', 'in_app'],
+      // 设计契约 task.reminder.daily:日提醒三字段(默认关闭,避免上线扰民)
+      dailyEnabled: 0,
+      morningTime: '07:30',
+      eveningTime: '21:00',
       _loaded: false
     }
   },
@@ -155,6 +185,10 @@ export default {
         }) : []
         self.completionPraiseEnabled = d.completionPraiseEnabled === 1 ? 1 : 0
         self.selectedPraiseChannels = Array.isArray(d.completionPraiseChannels) ? d.completionPraiseChannels.slice() : ['wechat', 'in_app']
+        // 设计契约 §3.3:日提醒默认关闭;后端已回显 morningTime / eveningTime
+        self.dailyEnabled = d.dailyEnabled === 1 ? 1 : 0
+        self.morningTime = d.morningTime || '07:30'
+        self.eveningTime = d.eveningTime || '21:00'
       }
     }).catch(function() {
       self.loading = false
@@ -206,6 +240,19 @@ export default {
       var n = parseInt(v, 10)
       this.offsetMinutes = isNaN(n) ? 0 : n
     },
+    toggleDailyEnabled: function() {
+      this.dailyEnabled = this.dailyEnabled === 1 ? 0 : 1
+    },
+    onMorningTimeChange: function(e) {
+      if (e && e.detail && e.detail.value) {
+        this.morningTime = e.detail.value
+      }
+    },
+    onEveningTimeChange: function(e) {
+      if (e && e.detail && e.detail.value) {
+        this.eveningTime = e.detail.value
+      }
+    },
     saveConfig: function() {
       var self = this
       if (!this.selectedChannels || this.selectedChannels.length === 0) {
@@ -225,7 +272,11 @@ export default {
         offsetMinutes: offset,
         enabled: this.enabled === 1 ? 1 : 0,
         completionPraiseEnabled: this.completionPraiseEnabled === 1 ? 1 : 0,
-        completionPraiseChannels: this.selectedPraiseChannels.slice()
+        completionPraiseChannels: this.selectedPraiseChannels.slice(),
+        // 设计契约 §3.3:前端回传日提醒三字段,后端校验 HH:mm + dailyEnabled ∈ {0,1}
+        dailyEnabled: this.dailyEnabled === 1 ? 1 : 0,
+        morningTime: this.morningTime,
+        eveningTime: this.eveningTime
       }).then(function(res) {
         self.saving = false
         if (res && res.code === 200) {
@@ -420,6 +471,37 @@ export default {
   font-size: calc(22rpx * var(--font-scale, 1));
   color: $app-text-subtle;
 }
+.daily-time-row {
+  display: flex;
+  gap: 24rpx;
+  margin-top: 20rpx;
+}
+.daily-time-item {
+  flex: 1;
+  box-sizing: border-box;
+  padding: 20rpx 24rpx;
+  background: $facade-fill-slate-50;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.daily-time-label {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $uni-text-color-secondary;
+}
+.daily-time-value {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $brand-primary-dark;
+  font-weight: 600;
+}
+.daily-time-hint {
+  display: block;
+  margin-top: 12rpx;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  line-height: 1.5;
+}
 .tip-card {
   background: $tint-sky-blue;
   border-radius: 16rpx;

+ 1 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -3536,7 +3536,7 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 |------|------|
 | `POST /api/user/update`、`/info`、`/profile/backfill`、`/profile-stats`;`POST /api/family/user/update`;`POST /api/profile/my` | 个人资料编辑(SC-P-64) |
 | `POST /api/notification/list`、`/read` | 消息中心/通知查看 + 已读(SC-P-65/78,`UserNotificationController`) |
-| `POST /api/task/reminder-config/get`、`/save` | 提醒设置(开关/渠道/时机/偏移,SC-P-65/79) |
+| `POST /api/task/reminder-config/get`、`/save` | 提醒设置(开关/渠道/时机/偏移/完成表扬 + **日提醒开关+早晚时间**,SC-P-65/79) |
 | `POST /api/consent/record`、`/withdraw`、`/status`、`/check` | 隐私授权同意(SC-P-66,`ConsentController`) |
 | `POST /api/notices/list` | 公告(SC-P-78,GET+POST 历史遗留) |
 #### 4.63.9 舌诊/情绪采集 · 账号 · 活动 · 反馈 · 会话(SC-P-70/71/76/83/85)