Bläddra i källkod

feat(task): 任务系统升级-行为调度中心后端(前置任务/频率任务/两阶段/填写式完成)

- tasks 表新增 prerequisite_task_id/action_type/action_config/require_input/start_required/start_count/min_duration_seconds
- 新增 task_executions 执行记录表(started/finished 两阶段落库)+ focus_sessions.task_id
- DatabaseInitializer 迁移248:ensureColumn 幂等 + CREATE TABLE IF NOT EXISTS
- TaskService:completeTask 前置校验/填写强校验/两阶段校验/最短时长校验,完成后解锁后置任务;新增 startTask(pending→in_progress 并记录 execution)
- TaskController 新增 POST /api/tasks/{id}/start;complete 支持 content/contentType
- RepeatTaskGenerator 重构为频率分派:hourly/daily/every_other_day/weekly/monthly,每小时 cron 扫描,兼容旧 repeatType
- CreateTaskDTO/CompleteTaskDTO 透传新字段

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 1 månad sedan
förälder
incheckning
52eb8aa47e

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

@@ -3243,6 +3243,40 @@ log.info("已添加template_id列到tasks表");
         modifyColumnToText("mini_games", "description", "游戏描述");
         // blood_type_config.description
         modifyColumnToText("blood_type_config", "description", "性格描述");
+
+        // 迁移248: 任务系统升级(行为调度中心)——tasks 新增字段
+        ensureColumn("tasks", "prerequisite_task_id", "BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务'");
+        ensureColumn("tasks", "action_type", "VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order'");
+        ensureColumn("tasks", "action_config", "JSON NULL COMMENT '联动参数: {\"gameCode\":\"schulte\",\"productId\":123,\"minSeconds\":30,\"completeMilestone\":\"paid\"/\"received\",\"surveyId\":99}'");
+        ensureColumn("tasks", "require_input", "TINYINT DEFAULT 0 COMMENT '1=需填写内容才能完成'");
+        ensureColumn("tasks", "start_required", "TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)'");
+        ensureColumn("tasks", "start_count", "INT DEFAULT 0 COMMENT '已开始次数(累计)'");
+        ensureColumn("tasks", "min_duration_seconds", "INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)'");
+
+        // 迁移248: 任务执行进度表(两阶段/里程碑/填写内容记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS task_executions (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "task_id BIGINT NOT NULL COMMENT '任务实例ID', " +
+                    "member_id BIGINT NOT NULL COMMENT '执行成员ID(family_members.id)', " +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'started' COMMENT 'started/finished/failed', " +
+                    "started_at DATETIME NOT NULL COMMENT '开始时间', " +
+                    "finished_at DATETIME COMMENT '完成时间', " +
+                    "milestone VARCHAR(32) COMMENT '购物任务里程碑: cart_added/paid/received(当前进度)', " +
+                    "content TEXT COMMENT '填写内容(require_input 任务)', " +
+                    "content_type VARCHAR(20) COMMENT 'content 类型: text/image/audio/video', " +
+                    "extra JSON COMMENT '扩展数据: {gameScore, focusMinutes, orderId, articleId, surveyId}', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_task_member (task_id, member_id), " +
+                    "INDEX idx_status (status)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行进度表'");
+            log.info("已创建task_executions表");
+        } catch (Exception e) {
+            log.warn("创建task_executions表失败: {}", e.getMessage());
+        }
+
+        // 迁移248: focus_sessions 添加 task_id 字段(专注任务挂钩)
+        ensureColumn("focus_sessions", "task_id", "BIGINT NULL COMMENT '关联的任务ID(专注任务挂钩)'");
     }
 
     private void modifyColumnToText(String table, String column, String comment) {

+ 22 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -100,7 +100,7 @@ public class TaskController {
             return Result.error(memberCheck.getMessage());
         }
         Long memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;
-        Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl());
+        Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl(), dto.getContent(), dto.getContentType());
         if (result != null) {
             // B17: 任务完成后检查问题域奖励
             try {
@@ -110,6 +110,27 @@ public class TaskController {
             }
         }
         return Result.success(result);
+    }
+
+    @Operation(summary = "开始任务(两阶段任务入口)")
+    @PostMapping("/{id}/start")
+    public Result<Map<String, Object>> startTask(@PathVariable Long id,
+                                                 @RequestBody(required = false) Map<String, Object> params,
+                                                 @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        Long memberId = params != null && params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        try {
+            Map<String, Object> result = taskService.startTask(id, memberId);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
     }
 
     @Operation(summary = "审核任务")

+ 11 - 5
cfc-backend/src/main/java/com/etotem/cfc/dto/CompleteTaskDTO.java

@@ -3,8 +3,14 @@ package com.etotem.cfc.dto;
 import lombok.Data;
 
 @Data
-public class CompleteTaskDTO {
-    private Long memberId;
-    private String photoUrl;
-    private String voiceNoteUrl;
-}
+public class CompleteTaskDTO {
+    private Long memberId;
+    private String photoUrl;
+    private String voiceNoteUrl;
+
+    // TASK-SYS-248: require_input 任务的填写内容
+    private String content;
+    // TASK-SYS-248: content 类型: text/image/audio/video
+    private String contentType;
+}
+

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java

@@ -53,4 +53,22 @@ public class CreateTaskDTO {
 
     // 会员任务标记: 1=会员专属 0=普通(B20,未缴费用户自建任务固定为0)
     private Integer memberOnly;
+
+    // TASK-SYS-248: 前置任务ID(完成后才解锁本任务)
+    private Long prerequisiteTaskId;
+
+    // TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order
+    private String actionType;
+
+    // TASK-SYS-248: 联动参数JSON
+    private String actionConfig;
+
+    // TASK-SYS-248: 1=需填写内容才能完成
+    private Integer requireInput;
+
+    // TASK-SYS-248: 1=需先开始再完成(两阶段)
+    private Integer startRequired;
+
+    // TASK-SYS-248: 最短持续秒数(专注/游戏类,未达标不计完成)
+    private Integer minDurationSeconds;
 }

+ 25 - 4
cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java

@@ -101,11 +101,32 @@ private Date acceptDeadline;
 	    // 来源ID(如文章ID、活动ID)
 	    private Long sourceId;
 
-	    /** 1=会员任务(方案/套餐生成, 执行需会员); 0=普通任务(自建可免费执行) */
-	    private Integer memberOnly;
+    /** 1=会员任务(方案/套餐生成, 执行需会员); 0=普通任务(自建可免费执行) */
+    private Integer memberOnly;
 
-	    /** 五维维度: body/mind/wisdom/action/wealth */
-	    private String dimensionCode;
+    /** TASK-SYS-248: 前置任务ID:完成后才解锁本任务 */
+    private Long prerequisiteTaskId;
+
+    /** TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order */
+    private String actionType;
+
+    /** TASK-SYS-248: 联动参数JSON: {"gameCode":"schulte","productId":123,"minSeconds":30,"completeMilestone":"paid","surveyId":99} */
+    private String actionConfig;
+
+    /** TASK-SYS-248: 1=需填写内容才能完成 */
+    private Integer requireInput;
+
+    /** TASK-SYS-248: 1=需先开始再完成(两阶段) */
+    private Integer startRequired;
+
+    /** TASK-SYS-248: 已开始次数(累计) */
+    private Integer startCount;
+
+    /** TASK-SYS-248: 最短持续秒数(专注/游戏类,未达标不计完成) */
+    private Integer minDurationSeconds;
+
+    /** 五维维度: body/mind/wisdom/action/wealth */
+    private String dimensionCode;
 
 	    /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
 	    private String dimensionWeights;

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskExecution.java

@@ -0,0 +1,50 @@
+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;
+
+/**
+ * TASK-SYS-248: 任务执行进度表
+ * 记录任务的两阶段执行(started/finished)、购物里程碑(cart_added/paid/received)与填写内容
+ */
+@Data
+@TableName("task_executions")
+public class TaskExecution implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 任务实例ID */
+    private Long taskId;
+
+    /** 执行成员ID(family_members.id) */
+    private Long memberId;
+
+    /** started/finished/failed */
+    private String status;
+
+    /** 开始时间 */
+    private Date startedAt;
+
+    /** 完成时间 */
+    private Date finishedAt;
+
+    /** 购物任务里程碑: cart_added/paid/received(当前进度) */
+    private String milestone;
+
+    /** 填写内容(require_input 任务) */
+    private String content;
+
+    /** content 类型: text/image/audio/video */
+    private String contentType;
+
+    /** 扩展数据JSON: {gameScore, focusMinutes, orderId, articleId, surveyId} */
+    private String extra;
+
+    private Date createdAt;
+}

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

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

+ 168 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -22,6 +22,9 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private TaskMapper taskMapper;
 
+    @Resource
+    private TaskExecutionMapper taskExecutionMapper;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -229,7 +232,17 @@ public class TaskService implements TaskServiceInterface {
 
 task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCategory() : 0);
         if (!"assigned".equals(task.getStatus())) {
-            task.setStatus("pending");
+            // TASK-SYS-248: 有前置任务且前置未完成 → 初始状态 locked
+            if (dto.getPrerequisiteTaskId() != null) {
+                Task prereq = taskMapper.selectById(dto.getPrerequisiteTaskId());
+                if (prereq == null || !"completed".equals(prereq.getStatus())) {
+                    task.setStatus("locked");
+                } else {
+                    task.setStatus("pending");
+                }
+            } else {
+                task.setStatus("pending");
+            }
         }
         task.setCreatedAt(new Date());
         task.setUpdatedAt(new Date());
@@ -241,6 +254,15 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         // B20: 会员任务标记透传(自建任务 memberOnly=0,不校验会员)
         task.setMemberOnly(dto.getMemberOnly() != null ? dto.getMemberOnly() : 0);
 
+        // TASK-SYS-248: 行为调度中心扩展字段透传
+        task.setPrerequisiteTaskId(dto.getPrerequisiteTaskId());
+        task.setActionType(dto.getActionType());
+        task.setActionConfig(dto.getActionConfig());
+        task.setRequireInput(dto.getRequireInput() != null ? dto.getRequireInput() : 0);
+        task.setStartRequired(dto.getStartRequired() != null ? dto.getStartRequired() : 0);
+        task.setStartCount(0);
+        task.setMinDurationSeconds(dto.getMinDurationSeconds() != null ? dto.getMinDurationSeconds() : 0);
+
         return task;
     }
 
@@ -275,7 +297,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             .eq(Task::getChildId, memberId)
             .ge(Task::getDeadline, startOfDay)
             .lt(Task::getDeadline, endOfDay)
-            .in(Task::getStatus, Arrays.asList("pending", "completed"))
+            .in(Task::getStatus, Arrays.asList("pending", "completed", "locked"))
             .orderByAsc(Task::getDeadline);
 
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
@@ -365,8 +387,56 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         return stats;
     }
 
+    /**
+     * TASK-SYS-248: 开始任务(两阶段任务入口)
+     * 校验前置/会员/频率后:pending→in_progress,创建 task_executions(started)
+     * 返回 executionId + 任务信息(前端据此跳转联动页面)
+     */
+    @Transactional
+    public Map<String, Object> startTask(Long taskId, Long memberId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null || !task.getChildId().equals(memberId)) {
+            throw new IllegalArgumentException("任务不存在或不属于该成员");
+        }
+        if ("completed".equals(task.getStatus())) {
+            throw new IllegalArgumentException("任务已完成");
+        }
+
+        // 前置任务校验
+        if (task.getPrerequisiteTaskId() != null) {
+            Task prereq = taskMapper.selectById(task.getPrerequisiteTaskId());
+            if (prereq == null || !"completed".equals(prereq.getStatus())) {
+                throw new IllegalArgumentException("需先完成前置任务:" + (prereq != null ? prereq.getTitle() : "未知任务"));
+            }
+        }
+
+        Date now = new Date();
+
+        // 状态流转:locked→pending 也允许开始(前端已解锁);其它状态置 in_progress
+        task.setStatus("in_progress");
+        task.setStartCount((task.getStartCount() == null ? 0 : task.getStartCount()) + 1);
+        task.setUpdatedAt(now);
+        taskMapper.updateById(task);
+
+        // 创建执行记录
+        TaskExecution exec = new TaskExecution();
+        exec.setTaskId(taskId);
+        exec.setMemberId(memberId);
+        exec.setStatus("started");
+        exec.setStartedAt(now);
+        exec.setCreatedAt(now);
+        taskExecutionMapper.insert(exec);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("executionId", exec.getId());
+        result.put("taskId", taskId);
+        result.put("actionType", task.getActionType());
+        result.put("actionConfig", task.getActionConfig());
+        return result;
+    }
+
     @Transactional
-    public Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl) {
+    public Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl, String content, String contentType) {
         Task task = taskMapper.selectById(taskId);
         if (task == null || !task.getChildId().equals(memberId)) {
             return null;
@@ -376,6 +446,50 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             return null; // 任务已完成,不能重复提交
         }
 
+        // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
+        if (task.getPrerequisiteTaskId() != null) {
+            Task prereq = taskMapper.selectById(task.getPrerequisiteTaskId());
+            if (prereq == null || !"completed".equals(prereq.getStatus())) {
+                throw new IllegalArgumentException("需先完成前置任务:" + (prereq != null ? prereq.getTitle() : "未知任务"));
+            }
+        }
+
+        // TASK-SYS-248: 填写式完成强校验——require_input=1 必须带内容
+        if (task.getRequireInput() != null && task.getRequireInput() == 1) {
+            boolean hasContent = (content != null && !content.trim().isEmpty()) || (photoUrl != null && !photoUrl.isEmpty());
+            if (!hasContent) {
+                throw new IllegalArgumentException("该任务需填写内容后才能完成");
+            }
+        }
+
+        // TASK-SYS-248: 两阶段任务校验——start_required=1 必须已开始(存在 in_progress 的 execution)
+        if (task.getStartRequired() != null && task.getStartRequired() == 1) {
+            Long executionCount = taskExecutionMapper.selectCount(
+                    new LambdaQueryWrapper<TaskExecution>()
+                            .eq(TaskExecution::getTaskId, taskId)
+                            .eq(TaskExecution::getMemberId, memberId)
+                            .eq(TaskExecution::getStatus, "started"));
+            if (executionCount == null || executionCount == 0) {
+                throw new IllegalArgumentException("该任务需先开始才能完成");
+            }
+
+            // 最短持续时长校验
+            if (task.getMinDurationSeconds() != null && task.getMinDurationSeconds() > 0) {
+                TaskExecution exec = taskExecutionMapper.selectOne(
+                        new LambdaQueryWrapper<TaskExecution>()
+                                .eq(TaskExecution::getTaskId, taskId)
+                                .eq(TaskExecution::getMemberId, memberId)
+                                .eq(TaskExecution::getStatus, "started")
+                                .last("LIMIT 1"));
+                if (exec != null && exec.getStartedAt() != null) {
+                    long elapsed = (System.currentTimeMillis() - exec.getStartedAt().getTime()) / 1000;
+                    if (elapsed < task.getMinDurationSeconds()) {
+                        throw new IllegalArgumentException("该任务需持续至少 " + task.getMinDurationSeconds() + " 秒后才能完成");
+                    }
+                }
+            }
+        }
+
         Date now = new Date();
         int pointsEarned;
 
@@ -486,6 +600,57 @@ log.warn("能量发放失败(不影响积分): memberId={}, taskId={}, error
 
         try { growthTaskService.updateProgress(child.getUserId(), "DAILY_TASK", 1); } catch (Exception e) { log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage()); }
 
+        // TASK-SYS-248: 更新任务执行进度(两阶段 execution 收尾 + 填写内容落库)
+        try {
+            TaskExecution activeExec = null;
+            if (task.getStartRequired() != null && task.getStartRequired() == 1) {
+                activeExec = taskExecutionMapper.selectOne(
+                        new LambdaQueryWrapper<TaskExecution>()
+                                .eq(TaskExecution::getTaskId, taskId)
+                                .eq(TaskExecution::getMemberId, memberId)
+                                .eq(TaskExecution::getStatus, "started")
+                                .last("LIMIT 1"));
+            }
+            if (activeExec != null) {
+                activeExec.setStatus("finished");
+                activeExec.setFinishedAt(now);
+                activeExec.setContent(content);
+                activeExec.setContentType(contentType);
+                taskExecutionMapper.updateById(activeExec);
+            } else if (content != null && !content.trim().isEmpty()) {
+                // 非两阶段任务的填写内容,落一条 finished 记录
+                TaskExecution exec = new TaskExecution();
+                exec.setTaskId(taskId);
+                exec.setMemberId(memberId);
+                exec.setStatus("finished");
+                exec.setStartedAt(now);
+                exec.setFinishedAt(now);
+                exec.setContent(content);
+                exec.setContentType(contentType);
+                exec.setCreatedAt(now);
+                taskExecutionMapper.insert(exec);
+            }
+        } catch (Exception e) {
+            log.warn("任务执行进度记录失败(不影响完成): taskId={}, error={}", taskId, e.getMessage());
+        }
+
+        // TASK-SYS-248: 前置链解锁——本任务完成后解锁依赖它的后置任务
+        try {
+            List<Task> nextTasks = taskMapper.selectList(
+                    new LambdaQueryWrapper<Task>()
+                            .eq(Task::getPrerequisiteTaskId, taskId));
+            for (Task next : nextTasks) {
+                if ("locked".equals(next.getStatus())) {
+                    next.setStatus("pending");
+                    next.setUpdatedAt(new Date());
+                    taskMapper.updateById(next);
+                    log.info("前置任务{}完成,解锁后置任务{}", taskId, next.getId());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("后置任务解锁失败: taskId={}, error={}", taskId, e.getMessage());
+        }
+
         Map<String, Object> result = new HashMap<>();
         result.put("pointsEarned", pointsEarned);
 result.put("newBalance", child.getTotalPoints());

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskServiceInterface.java

@@ -10,7 +10,8 @@ import java.util.Map;
 public interface TaskServiceInterface {
     Long createTask(Long userId, CreateTaskDTO dto);
     List<Task> getTodayTasks(Long memberId);
-    Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl);
+    Map<String, Object> startTask(Long taskId, Long memberId);
+    Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl, String content, String contentType);
     boolean reviewTask(Long taskId, TaskReviewDTO dto);
     Page<Task> getTaskHistory(Long memberId, Integer page, Integer size);
     boolean deleteTask(Long taskId, Long userId);

+ 134 - 64
cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java

@@ -13,8 +13,14 @@ import java.util.Date;
 import java.util.List;
 
 /**
- * TASK-004: 重复任务定时生成器
- * 每天凌晨自动生成每日/每周重复任务
+ * TASK-SYS-248: 重复任务定时生成器(频率分派版)
+ * 每小时执行一次,按 frequency 分派生成任务实例:
+ * - hourly: 每小时生成(无实例则生成)
+ * - daily: 每天生成(凌晨5点窗口内)
+ * - every_other_day: 隔天生成(按模板创建日奇偶)
+ * - weekly: 每周生成(按模板 deadline 的星期几)
+ * - monthly: 每月生成(按模板 deadline 的日)
+ * 兼容旧版 repeat_type (none/daily/weekly):repeat_type=daily 等价 frequency=daily,weekly 等价 weekly
  */
 @Slf4j
 @Component
@@ -24,12 +30,12 @@ public class RepeatTaskGenerator {
     private TaskMapper taskMapper;
 
     /**
-     * 每天凌晨5点执行,生成当天的重复任务
+     * 每小时第5分执行,按频率分派生成重复任务
      */
-    @Scheduled(cron = "0 0 5 * * ?")
+    @Scheduled(cron = "0 5 * * * ?")
     public void generateDailyTasks() {
-        log.info("开始生成重复任务...");
-        
+        log.info("开始按频率生成重复任务...");
+
         try {
             Date now = new Date();
             Calendar todayCal = Calendar.getInstance();
@@ -38,91 +44,130 @@ public class RepeatTaskGenerator {
             todayCal.set(Calendar.SECOND, 0);
             todayCal.set(Calendar.MILLISECOND, 0);
             Date today = todayCal.getTime();
-            
-            // 1. 处理每日重复任务
+
+            int dayOfWeek = todayCal.get(Calendar.DAY_OF_WEEK);
+            int weekday = dayOfWeek == 1 ? 7 : dayOfWeek - 1; // 周一=1...周日=7
+
+            // 1. 每日重复任务(frequency=daily,或旧版 repeat_type=daily)
             List<Task> dailyTasks = taskMapper.selectList(
                 new LambdaQueryWrapper<Task>()
-                    .eq(Task::getRepeatType, "daily")
-                    .eq(Task::getStatus, "pending")
+                    .eq(Task::getIsTemplate, 1)
+                    .and(w -> w.eq(Task::getFrequency, "daily")
+                        .or().eq(Task::getRepeatType, "daily"))
             );
-            
             for (Task template : dailyTasks) {
-                // 检查今天是否已存在该模板的任务
-                if (!hasTaskForDate(template, today)) {
-                    createTaskFromTemplate(template, today);
+                if (!hasTaskForDate(template, today, 86400000L)) {
+                    createTaskFromTemplate(template, today, false);
                 }
             }
-            
             log.info("每日重复任务生成完成,处理{}个模板", dailyTasks.size());
-            
-            // 2. 处理每周重复任务(今天是周几?)
-            int dayOfWeek = todayCal.get(Calendar.DAY_OF_WEEK);
-            // Calendar.SUNDAY = 1, MONDAY = 2, ... SATURDAY = 7
-            // 转换为周一=1 ... 周日=7
-            int weekday = dayOfWeek == 1 ? 7 : dayOfWeek - 1;
-            
-            // 获取每周重复的任务模板(存储在category或用其他方式)
-            // 这里假设原始模板任务的截止时间就是它应该在的星期几
+
+            // 2. 隔天重复任务(frequency=every_other_day)
+            List<Task> everyOtherDayTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 1)
+                    .eq(Task::getFrequency, "every_other_day")
+            );
+            for (Task template : everyOtherDayTasks) {
+                if (isEveryOtherDay(template, today)) {
+                    if (!hasTaskForDate(template, today, 86400000L)) {
+                        createTaskFromTemplate(template, today, false);
+                    }
+                }
+            }
+            log.info("隔天重复任务生成完成,处理{}个模板", everyOtherDayTasks.size());
+
+            // 3. 每周重复任务(frequency=weekly,或旧版 repeat_type=weekly)
             List<Task> weeklyTasks = taskMapper.selectList(
                 new LambdaQueryWrapper<Task>()
-                    .eq(Task::getRepeatType, "weekly")
-                    .eq(Task::getStatus, "pending")
+                    .eq(Task::getIsTemplate, 1)
+                    .and(w -> w.eq(Task::getFrequency, "weekly")
+                        .or().eq(Task::getRepeatType, "weekly"))
             );
-            
             for (Task template : weeklyTasks) {
-                // 检查模板的截止时间是周几
                 Calendar templateCal = Calendar.getInstance();
                 templateCal.setTime(template.getDeadline());
                 int templateDayOfWeek = templateCal.get(Calendar.DAY_OF_WEEK);
                 int templateWeekday = templateDayOfWeek == 1 ? 7 : templateDayOfWeek - 1;
-                
-                // 如果今天是该任务的执行日
                 if (weekday == templateWeekday) {
-                    if (!hasTaskForDate(template, today)) {
-                        createTaskFromTemplate(template, today);
+                    if (!hasTaskForDate(template, today, 86400000L)) {
+                        createTaskFromTemplate(template, today, false);
                     }
                 }
             }
-            
             log.info("每周重复任务生成完成,处理{}个模板", weeklyTasks.size());
-            
+
+            // 4. 每月重复任务(frequency=monthly)
+            List<Task> monthlyTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 1)
+                    .eq(Task::getFrequency, "monthly")
+            );
+            for (Task template : monthlyTasks) {
+                Calendar templateCal = Calendar.getInstance();
+                templateCal.setTime(template.getDeadline());
+                int templateDay = templateCal.get(Calendar.DAY_OF_MONTH);
+                int todayDay = todayCal.get(Calendar.DAY_OF_MONTH);
+                if (templateDay == todayDay) {
+                    if (!hasTaskForDate(template, today, 86400000L)) {
+                        createTaskFromTemplate(template, today, false);
+                    }
+                }
+            }
+            log.info("每月重复任务生成完成,处理{}个模板", monthlyTasks.size());
+
+            // 5. 每小时重复任务(frequency=hourly)——最近1小时无实例则生成
+            List<Task> hourlyTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                    .eq(Task::getIsTemplate, 1)
+                    .eq(Task::getFrequency, "hourly")
+            );
+            for (Task template : hourlyTasks) {
+                if (!hasTaskForDate(template, now, 3600000L)) {
+                    createTaskFromTemplate(template, now, true);
+                }
+            }
+            log.info("每小时重复任务生成完成,处理{}个模板", hourlyTasks.size());
+
         } catch (Exception e) {
             log.error("生成重复任务失败", e);
         }
     }
-    
+
     /**
-     * 检查指定日期是否已存在该模板的任务实例
+     * 隔天判定:模板创建时间到今天的间隔天数为偶数 → 今天生成
      */
-    private boolean hasTaskForDate(Task template, Date date) {
-        Calendar cal = Calendar.getInstance();
-        cal.setTime(date);
-        cal.set(Calendar.HOUR_OF_DAY, 23);
-        cal.set(Calendar.MINUTE, 59);
-        cal.set(Calendar.SECOND, 59);
-        Date endOfDay = cal.getTime();
-        
-        cal.set(Calendar.HOUR_OF_DAY, 0);
-        cal.set(Calendar.MINUTE, 0);
-        cal.set(Calendar.SECOND, 0);
-        Date startOfDay = cal.getTime();
-        
+    private boolean isEveryOtherDay(Task template, Date today) {
+        Date created = template.getCreatedAt() != null ? template.getCreatedAt() : template.getDeadline();
+        if (created == null) return true;
+        long diffDays = (today.getTime() - created.getTime()) / 86400000L;
+        return diffDays % 2 == 0;
+    }
+
+    /**
+     * 检查指定时间窗口内是否已存在该模板的任务实例
+     * @param windowMs 去重窗口(毫秒):86400000=当天,3600000=最近1小时
+     */
+    private boolean hasTaskForDate(Task template, Date date, long windowMs) {
+        Date windowEnd = new Date(date.getTime() + windowMs);
+
         Long count = taskMapper.selectCount(
             new LambdaQueryWrapper<Task>()
                 .eq(Task::getTitle, template.getTitle())
                 .eq(Task::getChildId, template.getChildId())
                 .eq(Task::getFamilyId, template.getFamilyId())
-                .ge(Task::getDeadline, startOfDay)
-                .le(Task::getDeadline, endOfDay)
+                .ge(Task::getCreatedAt, date)
+                .lt(Task::getCreatedAt, windowEnd)
         );
-        
+
         return count != null && count > 0;
     }
-    
+
 /**
 * 从模板创建新任务实例
+* @param useNow 是否使用当前时间作为截止时间(hourly 任务使用)
 */
-private void createTaskFromTemplate(Task template, Date targetDate) {
+private void createTaskFromTemplate(Task template, Date targetDate, boolean useNow) {
 Task newTask = new Task();
 newTask.setFamilyId(template.getFamilyId());
 newTask.setCreatorId(template.getCreatorId());
@@ -141,23 +186,48 @@ newTask.setTemplateId(template.getId()); // 指向模板
 newTask.setIsTemplate(0); // 这是实例,不是模板
 newTask.setMemberOnly(template.getMemberOnly()); // B21: 透传会员标记
 
-// 设置新的截止时间(保持原有时分,更新日期)
-Calendar newDeadline = Calendar.getInstance();
-newDeadline.setTime(targetDate);
+// TASK-SYS-248: 透传行为调度中心字段(action/填写/两阶段/最短时长)
+newTask.setPrerequisiteTaskId(template.getPrerequisiteTaskId());
+newTask.setActionType(template.getActionType());
+newTask.setActionConfig(template.getActionConfig());
+newTask.setRequireInput(template.getRequireInput());
+newTask.setStartRequired(template.getStartRequired());
+newTask.setStartCount(0);
+newTask.setMinDurationSeconds(template.getMinDurationSeconds());
 
-Calendar templateTime = Calendar.getInstance();
-templateTime.setTime(template.getDeadline());
-
-newDeadline.set(Calendar.HOUR_OF_DAY, templateTime.get(Calendar.HOUR_OF_DAY));
-newDeadline.set(Calendar.MINUTE, templateTime.get(Calendar.MINUTE));
+// 设置新的截止时间
+Calendar newDeadline = Calendar.getInstance();
+if (useNow) {
+    // hourly 任务:截止 = 当前时间 + 模板原时长(默认1小时)
+    int dur = template.getDuration() != null && template.getDuration() > 0 ? template.getDuration() : 60;
+    newDeadline.setTime(new Date(targetDate.getTime() + dur * 60000L));
+} else {
+    newDeadline.setTime(targetDate);
+    Calendar templateTime = Calendar.getInstance();
+    templateTime.setTime(template.getDeadline());
+    newDeadline.set(Calendar.HOUR_OF_DAY, templateTime.get(Calendar.HOUR_OF_DAY));
+    newDeadline.set(Calendar.MINUTE, templateTime.get(Calendar.MINUTE));
+}
 
 newTask.setDeadline(newDeadline.getTime());
 newTask.setRepeatType("none"); // 生成的实例不重复
-newTask.setStatus("pending");
+newTask.setFrequency("none");  // 实例不重复生成
 newTask.setCreatedAt(new Date());
 newTask.setUpdatedAt(new Date());
 
+// TASK-SYS-248: 有前置任务且前置未完成 → 实例初始状态 locked
+if (newTask.getPrerequisiteTaskId() != null) {
+    Task prereq = taskMapper.selectById(newTask.getPrerequisiteTaskId());
+    if (prereq == null || !"completed".equals(prereq.getStatus())) {
+        newTask.setStatus("locked");
+    } else {
+        newTask.setStatus("pending");
+    }
+} else {
+    newTask.setStatus("pending");
+}
+
 taskMapper.insert(newTask);
 log.info("已生成重复任务: {} (截止: {})", newTask.getTitle(), newTask.getDeadline());
 }
-}
+}

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

@@ -90,6 +90,14 @@ CREATE TABLE IF NOT EXISTS tasks (
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
     dimension_code VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth',
     dimension_weights JSON COMMENT '五维权重: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0}',
+    -- TASK-SYS-248: 行为调度中心扩展字段
+    prerequisite_task_id BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务',
+    action_type VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order',
+    action_config JSON NULL COMMENT '联动参数: {"gameCode":"schulte","productId":123,"minSeconds":30,"completeMilestone":"paid"/"received","surveyId":99}',
+    require_input TINYINT DEFAULT 0 COMMENT '1=需填写内容才能完成',
+    start_required TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)',
+    start_count INT DEFAULT 0 COMMENT '已开始次数(累计)',
+    min_duration_seconds INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_child_id_deadline (child_id, deadline),
@@ -100,6 +108,23 @@ CREATE TABLE IF NOT EXISTS tasks (
     INDEX idx_executor_assigned (executor_type, executor_id, status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+-- 任务执行进度表(两阶段/里程碑/填写内容记录)
+CREATE TABLE IF NOT EXISTS task_executions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    task_id BIGINT NOT NULL COMMENT '任务实例ID',
+    member_id BIGINT NOT NULL COMMENT '执行成员ID(family_members.id)',
+    status VARCHAR(20) NOT NULL DEFAULT 'started' COMMENT 'started/finished/failed',
+    started_at DATETIME NOT NULL COMMENT '开始时间',
+    finished_at DATETIME COMMENT '完成时间',
+    milestone VARCHAR(32) COMMENT '购物任务里程碑: cart_added/paid/received(当前进度)',
+    content TEXT COMMENT '填写内容(require_input 任务)',
+    content_type VARCHAR(20) COMMENT 'content 类型: text/image/audio/video',
+    extra JSON COMMENT '扩展数据: {gameScore, focusMinutes, orderId, articleId, surveyId}',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_task_member (task_id, member_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行进度表';
+
 -- 积分流水表
 CREATE TABLE IF NOT EXISTS points_log (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -226,6 +251,7 @@ CREATE TABLE IF NOT EXISTS focus_sessions (
     voice_note_url VARCHAR(255),
     started_at DATETIME NOT NULL,
     completed_at DATETIME,
+    task_id BIGINT NULL COMMENT '关联的任务ID(专注任务挂钩)',
     INDEX idx_child_id_completed (child_id, completed_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;