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

fix(health-plan): 打通方案→采购→服务→打卡→CF值→提醒全流程

- D1 转任务建立 prerequisite_task_id 依赖链,采购完成才激活服务任务
  依赖逐项配对(每个使用任务绑定其前面最近的购买任务),修复多商品共用首个锚点
- D2 周期服务任务写成 is_template=1 模板,交 RepeatTaskGenerator 按日生成实例
- D3 新增 health_plans.total_days,驱动方案截止日期与周期生成边界;/save 支持入参
- D4 新增 TaskReminderScheduledTask(每日09:00)+ task_reminder 模板 + 迁移345/346/347
  迁移347 用 LEFT JOIN 自连接修正 MySQL 1093;回填存量依赖并置 locked
- D5 打卡审核通过后发放 CF 值(user_platform_balance + family_platform_balance),按 task.points 1:1,幂等去重
- D6 结构化 planJson 支持 action_type=use;懒回填保真,使用任务不再被丢弃
Xiaogang Liao 3 дней назад
Родитель
Сommit
8ceb2f710c

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

@@ -10928,6 +10928,9 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         addPolicyTermsColumn();
         createHomeBannersTable();
         ensureHealthReportDraftsMemberIdColumn();
+        ensureHealthPlansTotalDaysColumn();
+        seedTaskReminderTemplate();
+        backfillHealthPlanTaskPrerequisite();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11508,6 +11511,61 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     }
 
     /** 迁移343: health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏,schema.sql 已含但无迁移脚本) */
+    /** 迁移345: health_plans 补 total_days 列(方案周期天数,驱动服务任务周期边界与截止日期) */
+    private void ensureHealthPlansTotalDaysColumn() {
+        ensureColumn("health_plans", "total_days",
+                "INT DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期'");
+        log.info("迁移345: health_plans.total_days 已确保存在");
+    }
+
+    /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
+    private void seedTaskReminderTemplate() {
+        try {
+            Integer cnt = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
+                    Integer.class, "task_reminder");
+            if (cnt != null && cnt > 0) {
+                log.info("迁移346: task_reminder 通知模板已存在,跳过");
+                return;
+            }
+            jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
+                    "task_reminder",
+                    "任务待完成提醒",
+                    "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
+                    "in_app,sms");
+            log.info("迁移346: task_reminder 通知模板已插入");
+        } catch (Exception e) {
+            log.warn("迁移346: 插入 task_reminder 通知模板失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移347: 回填健康方案任务的依赖链。
+     * 历史上使用任务只写了 parent_task_id,从未写 prerequisite_task_id,导致"采购完成才激活服务任务"的闸门失效。
+     * 这里补写依赖并把前置未完成的使用任务置为 locked。
+     * 幂等:每次启动只处理 prerequisite IS NULL / status=pending 的行,可重复执行。
+     */
+    private void backfillHealthPlanTaskPrerequisite() {
+        try {
+            int linked = jdbcTemplate.update(
+                    "UPDATE tasks SET prerequisite_task_id = parent_task_id " +
+                    "WHERE source_type = 'health_plan' AND category = '使用任务' " +
+                    "AND parent_task_id IS NOT NULL AND prerequisite_task_id IS NULL");
+            // 注意:单表 UPDATE 的 WHERE 子查询不能引用目标表本身(MySQL 1093),必须用 LEFT JOIN 自连接
+            int locked = jdbcTemplate.update(
+                    "UPDATE tasks t LEFT JOIN tasks p " +
+                    "ON p.id = t.prerequisite_task_id AND p.status = 'completed' " +
+                    "SET t.status = 'locked' " +
+                    "WHERE t.source_type = 'health_plan' AND t.category = '使用任务' " +
+                    "AND t.prerequisite_task_id IS NOT NULL AND t.status = 'pending' AND p.id IS NULL");
+            if (linked > 0 || locked > 0) {
+                log.info("迁移347: 健康方案任务依赖回填 prerequisite={}条, 置locked={}条", linked, locked);
+            }
+        } catch (Exception e) {
+            log.warn("迁移347: 健康方案任务依赖回填失败: " + e.getMessage());
+        }
+    }
+
     private void ensureHealthReportDraftsMemberIdColumn() {
         ensureColumn("health_report_drafts", "member_id",
                 "BIGINT DEFAULT NULL COMMENT '报告归属成员ID(family_members.id),A2/DAN自动采集必需'");

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -99,6 +99,12 @@ public class HealthPlanController {
         plan.setPlanJson(planJson);
         plan.setMemberName(memberName);
         plan.setTeacherId(teacherId);
+        // D3: 方案周期天数,驱动服务任务周期边界与方案截止日期(未传则后端默认14天)
+        // ParamUtils 无 getInt,用 getLong 后取值;同时限制上限避免异常大周期
+        Long totalDays = ParamUtils.getLong(params.get("totalDays"));
+        if (totalDays != null && totalDays > 0 && totalDays <= 365) {
+            plan.setTotalDays(totalDays.intValue());
+        }
         if (status != null) plan.setStatus(status);
         Long id = healthPlanService.savePlan(plan);
         return Result.success(id);

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java

@@ -26,6 +26,8 @@ public class HealthPlan implements Serializable {
     @TableField("reviewed_at") private Date reviewedAt;
     @TableField("review_comment") private String reviewComment;
     @TableField("teacher_id") private Long teacherId;
+    /** 方案周期天数:驱动服务任务的周期生成边界与截止日期(D3) */
+    @TableField("total_days") private Integer totalDays;
     @TableField("created_at") private Date createdAt;
     @TableField("updated_at") private Date updatedAt;
 

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -83,6 +83,12 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private TaskTemplateMapper taskTemplateMapper;
 
+    @Resource
+    private PlatformPointsService platformPointsService;
+
+    @Resource
+    private FamilyPlatformPointsService familyPlatformPointsService;
+
     private static final int EARLY_BONUS = 1; // 提前完成奖励
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
     private static final int LATE_GRACE_MINUTES = 10; // 10分钟内不算迟到
@@ -938,6 +944,24 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage());
         }
 
+        // D5: 打卡完成发放 CF 值(用户级 + 家庭级)。
+        // earn() 按 (id, ref_type, ref_id) 幂等去重,审核重复触发不会双记。
+        // CF 额度取任务基础积分,不含连胜里程碑加成与迟到惩罚,避免奖励被放大/倒挂。
+        try {
+            int cfAmount = task.getPoints() != null && task.getPoints() > 0 ? task.getPoints() : 0;
+            if (cfAmount > 0) {
+                String remark = "完成任务: " + task.getTitle();
+                if (child != null && child.getUserId() != null) {
+                    platformPointsService.earn(child.getUserId(), cfAmount, "task_complete", taskId, remark);
+                }
+                if (task.getFamilyId() != null) {
+                    familyPlatformPointsService.earn(task.getFamilyId(), cfAmount, "task_complete", taskId, remark);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("CF值发放失败(不影响积分): taskId={}, error={}", taskId, e.getMessage());
+        }
+
         unlockPrerequisiteTasks(taskId);
         return pointsEarned;
     }

+ 140 - 81
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -314,6 +314,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     /**
      * 解析方案文本,为每个任务项创建当天的 Task 记录
      */
+    /** 方案周期缺省天数(D3) */
+    private static final int DEFAULT_PLAN_DAYS = 14;
+
     private void generateDailyTasksFromPlan(HealthPlan plan) {
         String content = plan.getPlanContent();
         List<String> rawLines = new java.util.ArrayList<>();
@@ -336,12 +339,14 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         Long childId = familyMemberId;
         Long executorId = familyMemberId;
 
-        // 当天截止 23:59:59(严格落在今日,避免存成次日 00:00 导致今日任务查不到)
-        Date deadline = buildTodayDeadline();
-
         Date now = new Date();
 
-        // 统一生成:structured 分支直接用草稿;regex 分支按行分类
+        // 方案周期终点(D3):由 health_plans.total_days 决定,缺省 14 天。
+        // 既作为周期任务模板的截止日期(其时分=每日截止时刻),也作为周期生成的终止边界。
+        int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
+            ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
+        Date planEnd = buildPlanEnd(now, totalDays);
+
         List<TaskDraft> drafts = new java.util.ArrayList<>();
         if (usedStructured) {
             drafts = structuredDrafts;
@@ -354,95 +359,145 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
-        // 购买→使用子任务链的父任务ID(一次性购买任务完成后派生每日使用/服用)
+        // 1) 先落一次性购买任务,作为服务任务的依赖锚点(D1)
+        // 依赖必须"逐项配对":每个使用任务绑定它前面最近的购买任务。
+        // 若所有使用任务共用首个采购任务,多商品场景闸门语义就是错的(E2E 实跑发现)。
         Long buyTaskId = null;
-
+        List<Long> draftAnchors = new java.util.ArrayList<Long>();
         for (TaskDraft draft : drafts) {
-            // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
-            Calendar todayStart = Calendar.getInstance();
-            todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
-            todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
-            Calendar todayEnd = (Calendar) todayStart.clone();
-            todayEnd.add(Calendar.DAY_OF_MONTH, 1);
-
-            boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
-                .eq("family_id", familyId)
-                .eq("source_type", "health_plan")
-                .eq("source_id", plan.getId())
-                .eq("title", draft.title)
-                .ge("deadline", todayStart.getTime())
-                .lt("deadline", todayEnd.getTime())) > 0;
-            if (exists) {
-                if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
-                    Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
-                        .eq("family_id", familyId)
-                        .eq("source_type", "health_plan")
-                        .eq("source_id", plan.getId())
-                        .eq("title", draft.title)
-                        .eq("category", "购买任务")
-                        .last("LIMIT 1"));
-                    if (existingBuy != null) buyTaskId = existingBuy.getId();
-                }
+            if (!"购买任务".equals(draft.categoryLabel)) {
+                // 继承最近的购买锚点,供紧随其后的使用任务绑定
+                draftAnchors.add(buyTaskId);
                 continue;
             }
-
-            String repeatType = "once".equals(draft.frequency) ? "none" : "daily";
-            String taskType = "once".equals(draft.frequency) ? "onetime" : "recurring";
-            String freq = "once".equals(draft.frequency) ? null : "daily";
-
-            Task task = new Task();
-            task.setFamilyId(familyId);
-            task.setFamilyMemberId(familyMemberId);
-            task.setChildId(childId);
-            task.setExecutorType("child"); // 非登录用户(不可登录成员)
-            task.setExecutorId(executorId);
-            task.setCreatorId(executorId != null ? executorId : 0L);
-            task.setTitle(draft.title);
-            task.setDescription("");
-            task.setPoints(10);
-            task.setDeadline(deadline);
+            Task existing = findPlanTask(plan, familyId, draft.title, "购买任务", 0);
+            if (existing != null) {
+                buyTaskId = existing.getId();
+                draftAnchors.add(existing.getId());
+                continue;
+            }
+            Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
+            task.setRepeatType("none");
+            task.setTaskType("onetime");
+            task.setFrequency(null);
+            task.setIsTemplate(0);
+            task.setCategory(draft.categoryLabel);
+            task.setDimensionCode(draft.dimensionCode);
+            task.setDeadline(buildTodayDeadline());
             task.setStatus("pending");
-            task.setSourceType("health_plan");
-            task.setSourceId(plan.getId());
-            task.setNeedReview(1);
-            task.setCreatedAt(now);
-            task.setUpdatedAt(now);
-
-            if ("购买任务".equals(draft.categoryLabel)) {
-                task.setRepeatType(repeatType);
-                task.setTaskType(taskType);
-                task.setFrequency(freq);
-                task.setCategory(draft.categoryLabel);
-                task.setDimensionCode(draft.dimensionCode);
-                taskMapper.insert(task);
-                buyTaskId = task.getId();
-                log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
-            } else if ("使用任务".equals(draft.categoryLabel)) {
-                task.setRepeatType(repeatType);
-                task.setTaskType(taskType);
-                task.setFrequency(freq);
-                task.setCategory(draft.categoryLabel);
-                task.setDimensionCode(draft.dimensionCode);
-                task.setParentTaskId(buyTaskId != null ? buyTaskId : draft.parentTaskId);
-                taskMapper.insert(task);
-                log.info("方案{}生成每日使用子任务: [{}] {} (parent={})",
-                    plan.getId(), draft.categoryLabel, draft.title, task.getParentTaskId());
-            } else {
-                task.setRepeatType(repeatType);
-                task.setTaskType(taskType);
-                task.setFrequency(freq);
+            taskMapper.insert(task);
+            buyTaskId = task.getId();
+            draftAnchors.add(task.getId());
+            log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+        }
+
+        // 2) 再落其余任务:周期任务写成模板(D2),一次性任务仍是当天实例
+        for (int i = 0; i < drafts.size(); i++) {
+            TaskDraft draft = drafts.get(i);
+            if ("购买任务".equals(draft.categoryLabel)) continue;
+            Long draftAnchor = (i < draftAnchors.size()) ? draftAnchors.get(i) : buyTaskId;
+            boolean once = "once".equals(draft.frequency);
+
+            if (once) {
+                if (findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 0) != null) continue;
+                Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
+                task.setRepeatType("none");
+                task.setTaskType("onetime");
+                task.setFrequency(null);
+                task.setIsTemplate(0);
                 task.setCategory(draft.categoryLabel);
                 task.setDimensionCode(draft.dimensionCode);
+                task.setDeadline(buildTodayDeadline());
+                task.setStatus("pending");
                 taskMapper.insert(task);
-                log.info("方案{}生成任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+                log.info("方案{}生成一次性任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+                continue;
+            }
+
+            // 周期任务 -> 模板行(is_template=1),交 RepeatTaskGenerator 按日生成实例。
+            // 模板 deadline=planEnd(未来日期),因此不会出现在"今日任务"列表里(列表按 deadline=当天过滤),
+            // 用户只会看到生成出来的实例,天然避免模板/实例重复展示。
+            Task existingTpl = findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 1);
+            if (existingTpl != null) {
+                // 模板已存在:仅在依赖缺失时补写(修复存量方案已生成的模板)
+                if ("使用任务".equals(draft.categoryLabel)
+                        && existingTpl.getPrerequisiteTaskId() == null && draftAnchor != null) {
+                    existingTpl.setPrerequisiteTaskId(draftAnchor);
+                    existingTpl.setParentTaskId(draftAnchor);
+                    taskMapper.updateById(existingTpl);
+                    log.info("方案{}补写使用任务依赖: tplId={} -> buy={}",
+                        plan.getId(), existingTpl.getId(), draftAnchor);
+                }
+                continue;
+            }
+
+            Task tpl = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
+            tpl.setRepeatType("daily");
+            tpl.setTaskType("recurring");
+            tpl.setFrequency("daily");
+            tpl.setIsTemplate(1);
+            tpl.setCategory(draft.categoryLabel);
+            tpl.setDimensionCode(draft.dimensionCode);
+            tpl.setDeadline(planEnd);
+            tpl.setStatus("pending");
+            if ("使用任务".equals(draft.categoryLabel)) {
+                Long anchor = (draftAnchor != null) ? draftAnchor : draft.parentTaskId;
+                // D1: 真正的依赖闸门。生成器按前置完成状态决定实例 locked/pending,
+                // 完成后 TaskService.unlockPrerequisiteTasks 解锁 -> "采购完成自动激活服务任务"
+                tpl.setPrerequisiteTaskId(anchor);
+                tpl.setParentTaskId(anchor);
             }
+            taskMapper.insert(tpl);
+            log.info("方案{}生成周期任务模板: [{}] {} (freq=daily, 截止={}, prereq={})",
+                plan.getId(), draft.categoryLabel, draft.title, planEnd, tpl.getPrerequisiteTaskId());
         }
     }
 
-    /**
-     * 从 plan_json.sections[].tasks 构建任务草稿。返回 tasks 草稿列表;
-     * 若某 section 无 tasks,则用行分类逻辑兜底并回填 plan_json(懒加载),落库。
-     */
+    /** 方案周期终点:今天 + totalDays 的 23:59:59 */
+    private Date buildPlanEnd(Date now, int totalDays) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(now);
+        cal.add(Calendar.DAY_OF_MONTH, totalDays);
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
+    /** 幂等查找:同一方案下的同名任务,templateOnly=true 只找模板行 */
+    private Task findPlanTask(HealthPlan plan, Long familyId, String title, String category, int templateOnly) {
+        QueryWrapper<Task> qw = new QueryWrapper<Task>()
+            .eq("family_id", familyId)
+            .eq("source_type", "health_plan")
+            .eq("source_id", plan.getId())
+            .eq("title", title)
+            .eq("is_template", templateOnly);
+        if (category != null) qw.eq("category", category);
+        return taskMapper.selectOne(qw.last("LIMIT 1"));
+    }
+
+    /** 方案任务公共字段 */
+    private Task newPlanTask(HealthPlan plan, Long familyId, Long familyMemberId, Long childId,
+                             Long executorId, TaskDraft draft, Date now) {
+        Task task = new Task();
+        task.setFamilyId(familyId);
+        task.setFamilyMemberId(familyMemberId);
+        task.setChildId(childId);
+        task.setExecutorType("child");
+        task.setExecutorId(executorId);
+        task.setCreatorId(executorId != null ? executorId : 0L);
+        task.setTitle(draft.title);
+        task.setDescription("");
+        task.setPoints(10);
+        task.setSourceType("health_plan");
+        task.setSourceId(plan.getId());
+        task.setNeedReview(1);
+        task.setCreatedAt(now);
+        task.setUpdatedAt(now);
+        return task;
+    }
+
     private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
         List<TaskDraft> drafts = new java.util.ArrayList<>();
         String planJsonStr = plan.getPlanJson();
@@ -514,6 +569,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             case "checkin": cat = "打卡任务"; dim = "mind"; freq = "daily"; break;
             case "diet": cat = "饮食任务"; dim = "body"; freq = "daily"; break;
             case "activity": cat = "活动任务"; dim = "action"; freq = "daily"; break;
+            // D6: 结构化方案必须能表达"商品服务任务",否则购买→使用依赖链在结构化路径彻底丢失
+            case "use": cat = "使用任务"; dim = "body"; freq = "daily"; break;
             default: return null;
         }
         // dimension/frequency 显式值优先覆盖默认
@@ -534,6 +591,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             case "打卡任务": return "checkin";
             case "饮食任务": return "diet";
             case "活动任务": return "activity";
+            // D6: 懒回填必须保真,否则 action_type="" 会让使用任务在下次解析时被丢弃
+            case "使用任务": return "use";
             default: return "";
         }
     }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java

@@ -173,6 +173,12 @@ public class RepeatTaskGenerator {
 * @param useNow 是否使用当前时间作为截止时间(hourly 任务使用)
 */
 private void createTaskFromTemplate(Task template, Date targetDate, boolean useNow) {
+// D3 周期边界:模板 deadline 的日期部分即方案周期终点,超过后不再生成实例。
+// 实例 deadline = targetDate + 模板时分,故以 targetDate 与模板 deadline 比较。
+if (!useNow && template.getDeadline() != null && targetDate != null
+        && targetDate.after(template.getDeadline())) {
+    return;
+}
 Task newTask = new Task();
 newTask.setFamilyId(template.getFamilyId());
 newTask.setCreatorId(template.getCreatorId());

+ 123 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/TaskReminderScheduledTask.java

@@ -0,0 +1,123 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.DatabaseInitializer;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.Task;
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * D4: 未打卡任务消息提醒。
+ * 每天 09:00 扫描「今日到期 + 已逾期且仍未打卡」的任务,按家庭创建者(家长账号)推送提醒。
+ * 幂等:同一任务同一自然日最多提醒一次(以 user_notifications 的 (type,ref_type,ref_id,当日) 判重)。
+ */
+@Slf4j
+@Component
+public class TaskReminderScheduledTask {
+
+    private static final String TYPE = "task_reminder";
+    private static final String REF_TYPE = "task";
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    @Scheduled(cron = "0 0 9 * * ?")
+    public void remindUncheckedTasks() {
+        if (!DatabaseInitializer.isMigrationsComplete()) {
+            log.info("数据库迁移尚未完成,跳过本轮任务提醒");
+            return;
+        }
+        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();
+            Calendar endCal = (Calendar) cal.clone();
+            endCal.add(Calendar.DAY_OF_MONTH, 1);
+            Date dayEnd = endCal.getTime();
+
+            // 今日到期 + 已逾期,均为 pending(未打卡/未审核通过)
+            List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                .in(Task::getStatus, "pending", "pending_review")
+                .isNotNull(Task::getDeadline)
+                .isNotNull(Task::getFamilyId)
+                .le(Task::getDeadline, dayEnd)
+                .orderByAsc(Task::getDeadline));
+            if (tasks == null || tasks.isEmpty()) {
+                log.info("任务提醒:今日无待打卡任务");
+                return;
+            }
+
+            int sent = 0;
+            for (Task task : tasks) {
+                try {
+                    Long userId = resolveReceiver(task);
+                    if (userId == null) continue;
+                    if (alreadySentToday(userId, task.getId(), dayStart)) continue;
+
+                    boolean overdue = task.getDeadline() != null && task.getDeadline().before(now);
+                    Map<String, Object> vars = new HashMap<String, Object>();
+                    vars.put("title", task.getTitle());
+                    vars.put("status", overdue ? "已逾期" : "今日到期");
+
+                    notificationDispatchService.sendByTemplate(TYPE, userId, vars, REF_TYPE, task.getId(),
+                        "任务待完成:" + task.getTitle(),
+                        "任务「" + task.getTitle() + "」" + (overdue ? "已逾期" : "今日到期")
+                            + ",完成后记得打卡,可获得积分与 CF 值奖励。");
+                    sent++;
+                } catch (Exception e) {
+                    log.warn("任务提醒发送失败 taskId={}, error={}", task.getId(), e.getMessage());
+                }
+            }
+            log.info("任务提醒完成:候选{}条,实际发送{}条", tasks.size(), sent);
+        } catch (Exception e) {
+            log.warn("任务提醒任务异常: {}", e.getMessage());
+        }
+    }
+
+    /** 收件人:家庭创建者(家长账号) */
+    private Long resolveReceiver(Task task) {
+        if (task.getCreatorId() != null) {
+            Family f = familyMapper.selectById(task.getFamilyId());
+            if (f != null && f.getCreatorId() != null) return f.getCreatorId();
+        }
+        return null;
+    }
+
+    /** 同一任务同一自然日只提醒一次 */
+    private boolean alreadySentToday(Long userId, Long taskId, Date dayStart) {
+        Long cnt = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotification>()
+            .eq(UserNotification::getUserId, userId)
+            .eq(UserNotification::getType, TYPE)
+            .eq(UserNotification::getRefType, REF_TYPE)
+            .eq(UserNotification::getRefId, taskId)
+            .ge(UserNotification::getCreatedAt, dayStart));
+        return cnt != null && cnt > 0;
+    }
+}

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

@@ -4918,6 +4918,7 @@ CREATE TABLE IF NOT EXISTS health_plans (
     member_name   VARCHAR(50)  COMMENT '成员名称摘要',
     created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
     plan_json     TEXT         COMMENT '结构化方案内容(JSON)',
+    total_days    INT          DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期(默认14)',
     status        VARCHAR(20)  DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/confirmed(已确认)',
     reviewed_by   BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)',
     reviewed_at   DATETIME DEFAULT NULL COMMENT '审核时间',