2026-09-04-plan-to-task-unification.md 36 KB

方案转任务统一(Plan → Task Unification)实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 统一 4 套"方案转任务"体系——收敛套餐/测评方案为单一任务生成器,拆分 health_plan/training_plan 枚举消除 source_type='plan' 的 ID 域冲突,补齐缺失列与存量数据迁移。

架构:TaskPlanService 新增统一生成器 generateTasksFromTemplate 作为套餐/测评方案"方案→任务"唯一入口;HealthPlanServiceImpl 改用 source_type='health_plan'TrainingPlanService 补设 source_type='training_plan'tasks.source_typesource_id 一一对应,ProblemCompletionService 天然隔离。

技术栈: Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / MySQL 8.0(192.168.16.251:3306/cfc

关联设计: docs/superpowers/specs/2026-09-04-plan-to-task-unification-design.md

验证方式: 本项目以 mvn clean compile 为唯一编译验证(AGENTS.md 约定),测试基础设施有限(现有集成测试需真实 DB),故每任务以编译通过 + 代码审查为准,核心逻辑补充单元测试(可跑则跑,不可跑不阻塞)。


文件结构

文件 职责 变更类型
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java 统一任务生成器 generateTasksFromTemplateapplyAndActivatefindActiveByFamilyAndPackagecancelPlan 级联清理 修改
cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java 接口同步新增方法签名 修改
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java activate 改用统一生成器,删除重复模板任务逻辑 修改
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java confirm 改用 applyAndActivate,状态机收敛 修改
cfc-backend/src/main/java/com/etotem/cfc/controller/MarketController.java purchasePackageapplyAndActivate(幂等 + 空壳修复) 修改
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java source_type='plan''health_plan'(生成/查询/幂等) 修改
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java 接口注释同步(无代码变更) 修改
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java 新增 sourceType/sourceId 字段 修改
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java createSingleTask 透传 sourceType/sourceId 修改
cfc-backend/src/main/java/com/etotem/cfc/service/TrainingPlanService.java deployPlan 补设 training_plan 修改
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 迁移 295-297 修改
cfc-backend/src/main/resources/schema.sql task_plan_instances 建表补齐缺失列 修改
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java 统一生成器幂等/状态机单元测试 创建

任务 1:迁移 295 — task_plan_instances 补齐缺失列

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations 末尾,迁移 294 之后)
  • 修改:cfc-backend/src/main/resources/schema.sqltask_plan_instances 建表语句,第 625-641 行)

背景: TaskPlanInstance 实体已声明 source/sourceResultId/remark/reviewedBy/reviewedAt/reviewComment/confirmedBy/confirmedAt/activatedAt 字段,但 schema.sql 建表语句和 DB 生产库都缺这些列。PlanReviewController.reviewPlanreviewedByUnknown column 报错。

  • 步骤 1:在 DatabaseInitializer 添加迁移 295

runMigrations() 末尾(迁移 294 块之后)添加:

// 迁移295: task_plan_instances 补齐实体已含但 DB 缺失的审核/确认/激活列(review/activate 写字段报 Unknown column)
ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template'");
ensureColumn("task_plan_instances", "source_result_id", "BIGINT DEFAULT NULL COMMENT '关联测评结果ID(source=assessment时)'");
ensureColumn("task_plan_instances", "remark", "TEXT COMMENT '方案备注(含指导意见摘要)'");
ensureColumn("task_plan_instances", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)'");
ensureColumn("task_plan_instances", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'");
ensureColumn("task_plan_instances", "review_comment", "TEXT COMMENT '审核意见'");
ensureColumn("task_plan_instances", "confirmed_by", "BIGINT DEFAULT NULL COMMENT '确认人(家长ID)'");
ensureColumn("task_plan_instances", "confirmed_at", "DATETIME DEFAULT NULL COMMENT '确认时间'");
ensureColumn("task_plan_instances", "activated_at", "DATETIME DEFAULT NULL COMMENT '激活时间'");
log.info("迁移295: 已补齐 task_plan_instances 审核/确认/激活列");

ensureColumn 辅助方法已存在(DatabaseInitializer 第 4099 行附近),自动忽略"列已存在"异常。

  • 步骤 2:同步 schema.sql

schema.sql 第 625-641 行的 task_plan_instances 建表语句替换为:

CREATE TABLE IF NOT EXISTS task_plan_instances (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    package_id BIGINT NOT NULL,
    family_id BIGINT NOT NULL,
    child_id BIGINT NOT NULL,
    name VARCHAR(100) NOT NULL,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    status VARCHAR(20) DEFAULT 'active',
    total_days INT,
    completed_days INT DEFAULT 0,
    source VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template',
    source_result_id BIGINT DEFAULT NULL COMMENT '关联测评结果ID',
    remark TEXT COMMENT '方案备注',
    reviewed_by BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)',
    reviewed_at DATETIME DEFAULT NULL COMMENT '审核时间',
    review_comment TEXT COMMENT '审核意见',
    confirmed_by BIGINT DEFAULT NULL COMMENT '确认人(家长ID)',
    confirmed_at DATETIME DEFAULT NULL COMMENT '确认时间',
    activated_at DATETIME DEFAULT NULL COMMENT '激活时间',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_family_id (family_id),
    INDEX idx_child_id (child_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(task-plan): 迁移295补齐task_plan_instances审核/确认/激活列"
    

任务 2:TaskPlanService 统一任务生成器 generateTasksFromTemplate

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java

背景: 现有 generatePlanTasks(从 task_plan_items 复制、deadline=方案结束日、不设 familyMemberId)与 PlanActivationService.activate(从 task_template_items 直读、deadline=当天、设 familyMemberId)分裂。统一生成器以 task_template_items 为唯一来源,语义对齐 activate。

  • 步骤 1:在 TaskPlanService 新增统一生成器

TaskPlanService 中新增方法(替换/保留 generatePlanTasks 均可,本任务先新增,任务 3/4 改调用方):

/**
 * 统一方案任务生成器(设计: 2026-09-04-plan-to-task-unification-design.md)
 * 引导任务 + 从 task_template_items(按 sort_order)批量生成日常任务。
 * 幂等:sourceType=plan & sourceId=planId 已存在任务则跳过。
 * deadline:当天 23:59:59;familyMemberId:统一设置;memberOnly=1。
 */
@Transactional
public int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId) {
    if (plan == null) {
        throw new IllegalStateException("方案不存在");
    }
    Long planId = plan.getId();
    // 幂等:该方案已生成过任务则跳过
    Long existing = taskMapper.selectCount(
            new LambdaQueryWrapper<Task>()
                    .eq(Task::getSourceType, "plan")
                    .eq(Task::getSourceId, planId));
    if (existing != null && existing > 0) {
        return 0;
    }

    Date now = new Date();
    Date deadline = buildTodayDeadline();
    int count = 0;

    // 1. 引导任务(方案启动)
    Task guideTask = new Task();
    guideTask.setFamilyId(plan.getFamilyId());
    guideTask.setChildId(plan.getChildId());
    guideTask.setFamilyMemberId(plan.getChildId());
    guideTask.setCreatorId(operatorId);
    guideTask.setExecutorType("child");
    guideTask.setExecutorId(plan.getChildId());
    guideTask.setTitle("方案启动: " + plan.getName());
    guideTask.setDescription("请阅读并开始执行测评方案");
    guideTask.setPoints(2);
    guideTask.setCategory("成长");
    guideTask.setStatus("pending");
    guideTask.setSourceType("plan");
    guideTask.setSourceId(planId);
    guideTask.setNeedReview(0);
    guideTask.setIsTemplate(0);
    guideTask.setMemberOnly(1);
    guideTask.setDeadline(deadline);
    guideTask.setCreatedAt(now);
    guideTask.setUpdatedAt(now);
    taskMapper.insert(guideTask);
    count++;

    // 2. 模板任务(task_template_items 为唯一来源)
    if (plan.getPackageId() != null) {
        List<TaskTemplateItem> items = templateItemMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskTemplateItem>()
                        .eq("package_id", plan.getPackageId())
                        .orderByAsc("sort_order"));
        for (TaskTemplateItem item : items) {
            if (item.getTaskName() == null || item.getTaskName().isEmpty()) continue;
            Task task = new Task();
            task.setFamilyId(plan.getFamilyId());
            task.setChildId(plan.getChildId());
            task.setFamilyMemberId(plan.getChildId());
            task.setCreatorId(operatorId);
            task.setExecutorType("child");
            task.setExecutorId(plan.getChildId());
            task.setTitle(item.getTaskName());
            task.setDescription(item.getDescription());
            task.setPoints(item.getPoints() != null ? item.getPoints() : 2);
            task.setCategory("成长");
            task.setStatus("pending");
            task.setSourceType("plan");
            task.setSourceId(planId);
            task.setNeedReview(0);
            task.setIsTemplate(0);
            task.setMemberOnly(1);
            task.setFrequency(item.getFrequency() != null ? item.getFrequency() : "daily");
            task.setDuration(item.getDuration());
            task.setDeadline(deadline);
            task.setCreatedAt(now);
            task.setUpdatedAt(now);
            taskMapper.insert(task);
            count++;
        }
    }

    log.info("方案统一生成任务: planId={}, familyId={}, taskCount={}", planId, plan.getFamilyId(), count);
    return count;
}

/** 当天 23:59:59 截止(与 TaskService.getTodayTasks 区间对齐) */
private Date buildTodayDeadline() {
    Calendar cal = Calendar.getInstance();
    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();
}

需要新增 import:com.etotem.cfc.entity.TaskTemplateItemjava.util.CalendarTaskTemplateItem 已 import,Calendar 需确认)。

  • 步骤 2:同步接口

TaskPlanServiceInterface.java 新增:

int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId);
  • 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java
    git commit -m "feat(task-plan): 新增统一任务生成器generateTasksFromTemplate"
    

任务 3:PlanActivationService.activate 改用统一生成器

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java

背景: activate 现有引导任务 + 模板直读逻辑与统一生成器重复,且模板直读逻辑(不设 familyMemberId 的旧版)与统一生成器矛盾。改为直接调用统一生成器,删除重复代码。

  • 步骤 1:重写 activate 方法

注入 TaskPlanServiceactivate 方法体替换为:

@Resource
private TaskPlanService taskPlanService;

@Transactional
public void activate(Long planId, Long operatorId) {
    TaskPlanInstance plan = planMapper.selectById(planId);
    if (plan == null) throw new IllegalStateException("方案不存在");
    if (!"reviewed".equals(plan.getStatus())) {
        throw new IllegalStateException("方案状态异常,需要先审核通过");
    }
    // childId 兼容 user 或 family_members 两种 ID 域,任一存在即可激活
    boolean childExists = userMapper.selectById(plan.getChildId()) != null
            || familyMemberMapper.selectById(plan.getChildId()) != null;
    if (!childExists) throw new IllegalStateException("孩子不存在");

    // 统一任务生成器(引导任务 + 模板任务,幂等)
    taskPlanService.generateTasksFromTemplate(plan, operatorId);

    // 标记方案已激活
    plan.setStatus("active");
    plan.setActivatedAt(new Date());
    plan.setTotalDays(plan.getTotalDays() != null ? plan.getTotalDays() : 14);
    plan.setCompletedDays(0);
    plan.setUpdatedAt(new Date());
    planMapper.updateById(plan);

    log.info("方案激活 planId={}, childId={}", planId, plan.getChildId());
}

删除原 activate 中的引导任务生成、模板批量生成、fillDeadline 方法(已被统一生成器替代)。若 Task/TaskTemplateItem/taskMapper/templateItemMapper 不再被其他方法使用,移除对应 import 和 @Resource 字段(TaskMapper 若无他用可保留以防其他方法引用,编译检查为准)。

  • 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS(若报未使用 import 不影响编译,但保留无引用字段会告警,尽量清理)

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java
    git commit -m "refactor(task-plan): activate改用统一任务生成器,删除重复模板生成逻辑"
    

任务 4:ProblemPackageController.confirm 收敛状态机

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.javaapplyPackage 状态 + 新增 applyAndActivate/findActiveByFamilyAndPackage
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java

背景: applyPackage 直接建 active(无审核流程),confirm 后调 generatePlanTasks。改造后 applyPackagegeneratedconfirmapplyAndActivate(自助跳过审核置 active + 统一生成)。

  • 步骤 1:applyPackage 状态改为 generated

TaskPlanService.applyPackage 第 82 行:

instance.setStatus("active");

改为:

instance.setStatus("generated"); // 状态机: generated → reviewed → active(见统一设计)
  • 步骤 2:新增 applyAndActivate + findActiveByFamilyAndPackage

TaskPlanService 新增:

/**
 * 家长自助应用套餐并激活(confirm / purchase 共用):
 * 1. 幂等:同家庭同套餐已有 active 实例则复用
 * 2. 新建则 applyPackage(generated)→ 置 active(自助跳过审核)
 * 3. 统一生成器生成任务
 */
@Transactional
public Map<String, Object> applyAndActivate(ApplyPackageDTO dto, Long familyId, Long userId) {
    TaskPlanInstance plan = findActiveByFamilyAndPackage(familyId, dto.getPackageId());
    boolean created = false;
    if (plan == null) {
        plan = applyPackage(dto, familyId);
        created = true;
    }
    if (created) {
        Date now = new Date();
        plan.setStatus("active");
        plan.setActivatedAt(now);
        plan.setUpdatedAt(now);
        instanceMapper.updateById(plan);
    }
    int taskCount = generateTasksFromTemplate(plan, userId);
    Map<String, Object> result = new HashMap<>();
    result.put("planId", plan.getId());
    result.put("taskCount", taskCount);
    return result;
}

/** 查询同家庭同套餐的 active 实例(幂等复用) */
public TaskPlanInstance findActiveByFamilyAndPackage(Long familyId, Long packageId) {
    return instanceMapper.selectOne(
            new LambdaQueryWrapper<TaskPlanInstance>()
                    .eq(TaskPlanInstance::getFamilyId, familyId)
                    .eq(TaskPlanInstance::getPackageId, packageId)
                    .eq(TaskPlanInstance::getStatus, "active")
                    .last("LIMIT 1"));
}

需要新增 import:java.util.HashMapjava.util.Map(确认 TaskPlanService 现有 import)。

  • 步骤 3:接口同步

TaskPlanServiceInterface.java 新增:

Map<String, Object> applyAndActivate(ApplyPackageDTO dto, Long familyId, Long userId);
TaskPlanInstance findActiveByFamilyAndPackage(Long familyId, Long packageId);
  • 步骤 4:confirm 改用 applyAndActivate

ProblemPackageController.confirm 第 136-169 行,将"幂等复用 + applyPackage + generatePlanTasks"块替换为:

// 应用套餐并激活(幂等复用 + 自助置 active + 统一生成任务)
ApplyPackageDTO dto = new ApplyPackageDTO();
dto.setPackageId(packageId);
Map<String, Object> applied = taskPlanService.applyAndActivate(dto, familyId, userId);
Long planId = (Long) applied.get("planId");
int taskCount = (Integer) applied.get("taskCount");

删除原 planInstanceMapper 幂等查询块(第 135-150 行)和 generatePlanTasks 调用(第 153 行)。若 planInstanceMapper 不再被 confirm 使用,检查 Controller 其他方法是否引用(本 Controller 仅 confirm 用),删除对应 @Resource 字段和 import。

  • 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java
    git commit -m "feat(task-plan): 状态机收敛generated→active,confirm改走applyAndActivate"
    

任务 5:MarketController.purchasePackage 幂等 + 空壳修复

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/MarketController.java

背景: purchasePackageapplyPackage 建 active 实例但不生成任务("active 但零任务"空壳),且可无限创建同套餐实例。

  • 步骤 1:purchasePackage 改用 applyAndActivate

MarketController.purchasePackage 第 84-96 行替换为:

@Operation(summary = "购买并应用任务模板")
@PostMapping("/packages/{id}/purchase")
public Result<Map<String, Object>> purchasePackage(
        @PathVariable Long id,
        @RequestBody ApplyPackageDTO dto,
        @RequestAttribute("userId") Long userId) {
    Long familyId = membershipService.getUserFamilyId(userId);
    if (familyId == null) {
        return Result.noFamily("请先创建或加入家庭");
    }
    dto.setPackageId(id);
    Map<String, Object> applied = planService.applyAndActivate(dto, familyId, userId);
    return Result.success(applied);
}

返回类型从 Result<TaskPlanInstance> 改为 Result<Map<String, Object>>(含 planId + taskCount)。若前端有调用方依赖原返回结构,需确认——搜索 purchasePackage 前端调用(见下方验证)。

  • 步骤 2:检查前端调用方是否兼容

运行:grep -rn "packages/.*/purchase\|purchasePackage" cfc-frontend/utils/api.js cfc-frontend/pages/ 2>/dev/null 预期:若前端依赖 data.iddata.name,需同步适配(本次范围含前端小改动;若前端完全未调用,跳过)。

  • 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/MarketController.java
    git commit -m "fix(task-plan): purchasePackage补幂等复用并生成任务,消除active空壳"
    

任务 6:TaskPlanService.cancelPlan 级联清理

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java

背景: cancelPlan 只改实例状态,已生成的 sourceType='plan' 任务仍可执行、仍参与 ProblemCompletionService 完成度统计。

  • 步骤 1:cancelPlan 级联清理未完成任务

TaskPlanService.cancelPlan(第 202-210 行)替换为:

@Transactional
public boolean cancelPlan(Long id) {
    TaskPlanInstance instance = instanceMapper.selectById(id);
    if (instance == null || !"active".equals(instance.getStatus())) {
        return false;
    }
    instance.setStatus("cancelled");
    instance.setUpdatedAt(new Date());
    boolean updated = instanceMapper.updateById(instance) > 0;

    // 级联清理:该方案生成的未完成任务置 cancelled
    if (updated) {
        List<Task> pendingTasks = taskMapper.selectList(
                new LambdaQueryWrapper<Task>()
                        .eq(Task::getSourceType, "plan")
                        .eq(Task::getSourceId, id)
                        .in(Task::getStatus, "pending", "pending_review", "locked", "in_progress"));
        for (Task t : pendingTasks) {
            t.setStatus("cancelled");
            t.setUpdatedAt(new Date());
            taskMapper.updateById(t);
        }
        log.info("取消方案{}并级联清理{}条未完成任务", id, pendingTasks.size());
    }
    return updated;
}
  • 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java
    git commit -m "fix(task-plan): cancelPlan级联清理方案未完成任务"
    

任务 7:健康方案 source_typehealth_plan

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java(注释同步)

背景: 健康方案与套餐/测评方案共用 source_type='plan'source_id 指向 health_plans,与 task_plan_instances ID 域冲突,导致 ProblemCompletionService 误统计。拆分枚举。

  • 步骤 1:生成任务改 source_type

HealthPlanServiceImpl.generateDailyTasksFromPlan 第 404 行:

task.setSourceType("plan");

改为:

task.setSourceType("health_plan");
  • 步骤 2:getPlanTasks 查询改 source_type

HealthPlanServiceImpl.getPlanTasks 第 196 行:

wrapper.eq("source_type", "plan")

改为:

wrapper.eq("source_type", "health_plan")
  • 步骤 3:幂等检查改 source_type

generateDailyTasksFromPlan 第 369 行(幂等 exists 检查)和第 378 行(existingBuy 查询):

.eq("source_type", "plan")

改为:

.eq("source_type", "health_plan")
  • 步骤 4:接口注释同步

HealthPlanService.java 第 36 行注释:

/** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */

改为:

/** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
  • 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
    git commit -m "feat(task-source): 健康方案任务source_type改为health_plan,消除与plan的ID域冲突"
    

任务 8:认知训练方案补设 training_plan

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/TrainingPlanService.java

背景: TrainingPlanService.deployPlantaskService.createTask,但 createSingleTask 不设 sourceType/sourceId,任务无法溯源。

  • 步骤 1:CreateTaskDTO 新增字段

CreateTaskDTO.java 末尾(minDurationSeconds 之后)新增:

// 来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase
private String sourceType;

// 来源ID(如 training_plans.id / task_plan_instances.id)
private Long sourceId;
  • 步骤 2:createSingleTask 透传

TaskService.createSingleTask 末尾(第 288 行 setMinDurationSeconds 之后)新增:

// 来源类型透传(方案/模板/联动生成任务用于溯源)
task.setSourceType(dto.getSourceType());
task.setSourceId(dto.getSourceId());
  • 步骤 3:deployPlan 补设 sourceType

TrainingPlanService.deployPlan 中,CreateTaskDTO dto 创建后(第 149-155 行之间)新增:

dto.setSourceType("training_plan");
dto.setSourceId(planId);
  • 步骤 4:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java cfc-backend/src/main/java/com/etotem/cfc/service/TrainingPlanService.java
    git commit -m "feat(task-source): 认知训练方案任务补设source_type=training_plan可溯源"
    

任务 9:迁移 296/297 — 存量数据枚举修正

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

背景: 存量 tasks 中健康方案任务已标 source_type='plan'、认知训练方案任务未标 sourceType。需迁移修正。迁移前做冲突检测:source_type='plan' 的任务里,source_id 同时存在于 health_planstask_plan_instances 的数量,若 >0 需人工确认。

  • 步骤 1:添加迁移 296(健康方案枚举修正 + 冲突检测)

在迁移 295 块之后添加:

// 迁移296: 健康方案任务 source_type 'plan' -> 'health_plan'(消除与 task_plan_instances 的 ID 域冲突)
try {
    // 冲突检测:source_id 同时存在于 health_plans 和 task_plan_instances 的任务(需人工确认)
    Integer conflict = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM tasks t WHERE t.source_type = 'plan' " +
            "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = t.source_id) " +
            "AND EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = t.source_id)",
            Integer.class);
    if (conflict != null && conflict > 0) {
        log.warn("迁移296: 检测到{}条冲突任务(source_id同时存在于health_plans和task_plan_instances),跳过迁移,需人工确认", conflict);
    } else {
        int updated = jdbcTemplate.update(
                "UPDATE tasks SET source_type = 'health_plan' " +
                "WHERE source_type = 'plan' " +
                "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = tasks.source_id) " +
                "AND NOT EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = tasks.source_id)");
        log.info("迁移296: 已修正{}条健康方案任务 source_type -> health_plan", updated);
    }
} catch (Exception e) {
    log.warn("迁移296执行失败(health_plans 表可能不存在): {}", e.getMessage());
}
  • [ ] 步骤 2:添加迁移 297(认知训练方案枚举补设)

    // 迁移297: 认知训练方案任务补设 source_type='training_plan'(training_plan_tasks 关联溯源)
    try {
    Integer updated = jdbcTemplate.update(
            "UPDATE tasks t SET t.source_type = 'training_plan' " +
            "WHERE (t.source_type IS NULL OR t.source_type = '') " +
            "AND EXISTS (SELECT 1 FROM training_plan_tasks pt WHERE pt.task_id = t.id)");
    log.info("迁移297: 已补设{}条认知训练方案任务 source_type -> training_plan", updated);
    } catch (Exception e) {
    log.warn("迁移297执行失败(training_plan_tasks 表可能不存在): {}", e.getMessage());
    }
    
  • [ ] 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git commit -m "feat(task-source): 迁移296/297修正存量健康/认知训练方案任务source_type"
    

任务 10:单元测试 + 最终验证

文件:

  • 创建:cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java

背景: 项目测试基础设施有限(集成测试需真实 DB),本任务用 JUnit + Mockito 对统一生成器做纯逻辑单测(mock Mapper),可脱离 DB 运行。

  • [ ] 步骤 1:编写单元测试

    package com.etotem.cfc.unit;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.Task;
    import com.etotem.cfc.entity.TaskPlanInstance;
    import com.etotem.cfc.entity.TaskTemplateItem;
    import com.etotem.cfc.mapper.TaskMapper;
    import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
    import com.etotem.cfc.mapper.TaskPlanItemMapper;
    import com.etotem.cfc.mapper.TaskTemplateItemMapper;
    import com.etotem.cfc.service.TaskPlanService;
    import com.etotem.cfc.service.TaskTemplatePackageService;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    
    import java.lang.reflect.Field;
    import java.util.Collections;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.*;
    
    /**
    * 统一任务生成器单元测试(纯逻辑,mock Mapper,无需 DB)
    */
    class TaskPlanServiceTest {
    
    private TaskPlanService service;
    private TaskMapper taskMapper;
    private TaskPlanInstanceMapper instanceMapper;
    private TaskPlanItemMapper planItemMapper;
    private TaskTemplateItemMapper templateItemMapper;
    private TaskTemplatePackageService packageService;
    
    @BeforeEach
    void setUp() throws Exception {
        service = new TaskPlanService();
        taskMapper = mock(TaskMapper.class);
        instanceMapper = mock(TaskPlanInstanceMapper.class);
        planItemMapper = mock(TaskPlanItemMapper.class);
        templateItemMapper = mock(TaskTemplateItemMapper.class);
        packageService = mock(TaskTemplatePackageService.class);
        inject("taskMapper", taskMapper);
        inject("instanceMapper", instanceMapper);
        inject("planItemMapper", planItemMapper);
        inject("templateItemMapper", templateItemMapper);
        inject("packageService", packageService);
    }
    
    private void inject(String field, Object value) throws Exception {
        Field f = TaskPlanService.class.getDeclaredField(field);
        f.setAccessible(true);
        f.set(service, value);
    }
    
    @Test
    void generateTasksFromTemplate_已生成过则跳过() {
        TaskPlanInstance plan = new TaskPlanInstance();
        plan.setId(1L);
        plan.setFamilyId(10L);
        plan.setChildId(20L);
        plan.setName("测试方案");
        when(taskMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(5L);
    
        int count = service.generateTasksFromTemplate(plan, 99L);
    
        assertEquals(0, count);
        verify(taskMapper, never()).insert(any(Task.class));
    }
    
    @Test
    void generateTasksFromTemplate_生成引导任务和模板任务() {
        TaskPlanInstance plan = new TaskPlanInstance();
        plan.setId(1L);
        plan.setFamilyId(10L);
        plan.setChildId(20L);
        plan.setName("测试方案");
        plan.setPackageId(30L);
        when(taskMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
    
        TaskTemplateItem item = new TaskTemplateItem();
        item.setTaskName("每日运动");
        item.setPoints(5);
        item.setFrequency("daily");
        when(templateItemMapper.selectList(any(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class)))
                .thenReturn(Collections.singletonList(item));
    
        int count = service.generateTasksFromTemplate(plan, 99L);
    
        // 引导任务 + 1 模板任务
        assertEquals(2, count);
        verify(taskMapper, times(2)).insert(any(Task.class));
    }
    
    @Test
    void cancelPlan_级联清理未完成任务() {
        TaskPlanInstance plan = new TaskPlanInstance();
        plan.setId(1L);
        plan.setFamilyId(10L);
        plan.setChildId(20L);
        plan.setStatus("active");
        when(instanceMapper.selectById(1L)).thenReturn(plan);
        when(instanceMapper.updateById(any(TaskPlanInstance.class))).thenReturn(1);
        when(taskMapper.selectList(any(LambdaQueryWrapper.class)))
                .thenReturn(Collections.singletonList(new Task()));
    
        boolean ok = service.cancelPlan(1L);
    
        assertEquals(true, ok);
        verify(taskMapper, times(1)).updateById(any(Task.class));
    }
    }
    

说明:TaskPlanService.cancelPlan 需确认是 public(是,第 202 行)。测试依赖 Lombok 生成的 getter/setter,@Data 已具备。若项目未引入 spring-boot-starter-test 的 JUnit5 + Mockito,则此测试文件可能无法编译——运行下述命令确认,若依赖缺失则跳过本任务测试(标注原因),以编译验证替代。

  • 步骤 2:运行测试(若依赖可用)

运行:cd cfc-backend && mvn test -Dtest=TaskPlanServiceTest -pl . 2>&1 | tail -20 预期:Tests run: 3, Failures: 0 或明确依赖缺失错误

若报 Cannot resolve symbol 'org.junit.jupiter''org.mockito',说明测试依赖缺失 → 检查 pom.xml 是否含 spring-boot-starter-test;若确无,删除本测试文件(git rm)并在 commit message 注明,以 mvn clean compile 作为验证。

  • 步骤 3:全量编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • 步骤 4:路由冲突检查

运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep -E "packages|problem-package|guide/plans" 预期:无新增重复路由

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java
    git commit -m "test(task-plan): 统一任务生成器幂等/生成/级联清理单元测试"
    

自检

1. 规格覆盖度:

规格章节 对应任务
4.2 task_plan_instances 补齐列 任务 1
5.1 单一任务生成器 任务 2
5.2 状态机收敛 任务 4
5.3 健康方案 health_plan 任务 7
5.4 认知训练方案 training_plan 任务 8
5.5 幂等与空壳修复 任务 5
5.5 cancelPlan 级联清理 任务 6
6 数据迁移 任务 9
7 测试 任务 10

2. 占位符扫描: 无 TODO/待定,所有步骤含完整代码和命令。

3. 类型一致性: generateTasksFromTemplate(TaskPlanInstance, Long)applyAndActivate(ApplyPackageDTO, Long, Long)findActiveByFamilyAndPackage(Long, Long) 三个新方法在任务 2-5 中签名一致;applyAndActivate 返回 Map<String, Object>(planId + taskCount)在任务 4/5 消费方式一致。

4. 风险提示: 任务 5 改了 purchasePackage 返回类型(TaskPlanInstanceMap),需确认前端调用方;任务 10 测试依赖 JUnit5/Mockito 是否在 pom 中,缺失则降级为编译验证。