Jelajahi Sumber

docs: 方案转任务统一设计 + 实现计划(source_type拆分 + 统一生成器 + 状态机收敛)

iwt 2 bulan lalu
induk
melakukan
d25c72fe43

+ 885 - 0
docs/superpowers/plans/2026-09-04-plan-to-task-unification.md

@@ -0,0 +1,885 @@
+# 方案转任务统一(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_type` 与 `source_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` | 统一任务生成器 `generateTasksFromTemplate`、`applyAndActivate`、`findActiveByFamilyAndPackage`、`cancelPlan` 级联清理 | 修改 |
+| `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` | `purchasePackage` 改 `applyAndActivate`(幂等 + 空壳修复) | 修改 |
+| `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.sql`(`task_plan_instances` 建表语句,第 625-641 行)
+
+**背景:** `TaskPlanInstance` 实体已声明 `source`/`sourceResultId`/`remark`/`reviewedBy`/`reviewedAt`/`reviewComment`/`confirmedBy`/`confirmedAt`/`activatedAt` 字段,但 `schema.sql` 建表语句和 DB 生产库都缺这些列。`PlanReviewController.reviewPlan` 写 `reviewedBy` 会 `Unknown column` 报错。
+
+- [ ] **步骤 1:在 DatabaseInitializer 添加迁移 295**
+
+在 `runMigrations()` 末尾(迁移 294 块之后)添加:
+
+```java
+// 迁移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` 建表语句替换为:
+
+```sql
+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**
+
+```bash
+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 改调用方):
+
+```java
+/**
+ * 统一方案任务生成器(设计: 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.TaskTemplateItem`、`java.util.Calendar`(`TaskTemplateItem` 已 import,Calendar 需确认)。
+
+- [ ] **步骤 2:同步接口**
+
+`TaskPlanServiceInterface.java` 新增:
+
+```java
+int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId);
+```
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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 方法**
+
+注入 `TaskPlanService`,`activate` 方法体替换为:
+
+```java
+@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**
+
+```bash
+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.java`(`applyPackage` 状态 + 新增 `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`。改造后 `applyPackage` 建 `generated`,`confirm` 走 `applyAndActivate`(自助跳过审核置 active + 统一生成)。
+
+- [ ] **步骤 1:applyPackage 状态改为 generated**
+
+`TaskPlanService.applyPackage` 第 82 行:
+
+```java
+instance.setStatus("active");
+```
+
+改为:
+
+```java
+instance.setStatus("generated"); // 状态机: generated → reviewed → active(见统一设计)
+```
+
+- [ ] **步骤 2:新增 applyAndActivate + findActiveByFamilyAndPackage**
+
+`TaskPlanService` 新增:
+
+```java
+/**
+ * 家长自助应用套餐并激活(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.HashMap`、`java.util.Map`(确认 TaskPlanService 现有 import)。
+
+- [ ] **步骤 3:接口同步**
+
+`TaskPlanServiceInterface.java` 新增:
+
+```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"块替换为:
+
+```java
+// 应用套餐并激活(幂等复用 + 自助置 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**
+
+```bash
+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`
+
+**背景:** `purchasePackage` 只 `applyPackage` 建 active 实例但不生成任务("active 但零任务"空壳),且可无限创建同套餐实例。
+
+- [ ] **步骤 1:purchasePackage 改用 applyAndActivate**
+
+`MarketController.purchasePackage` 第 84-96 行替换为:
+
+```java
+@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.id` 或 `data.name`,需同步适配(本次范围含前端小改动;若前端完全未调用,跳过)。
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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 行)替换为:
+
+```java
+@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**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java
+git commit -m "fix(task-plan): cancelPlan级联清理方案未完成任务"
+```
+
+---
+
+### 任务 7:健康方案 `source_type` → `health_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 行:
+
+```java
+task.setSourceType("plan");
+```
+
+改为:
+
+```java
+task.setSourceType("health_plan");
+```
+
+- [ ] **步骤 2:getPlanTasks 查询改 source_type**
+
+`HealthPlanServiceImpl.getPlanTasks` 第 196 行:
+
+```java
+wrapper.eq("source_type", "plan")
+```
+
+改为:
+
+```java
+wrapper.eq("source_type", "health_plan")
+```
+
+- [ ] **步骤 3:幂等检查改 source_type**
+
+`generateDailyTasksFromPlan` 第 369 行(幂等 exists 检查)和第 378 行(existingBuy 查询):
+
+```java
+.eq("source_type", "plan")
+```
+
+改为:
+
+```java
+.eq("source_type", "health_plan")
+```
+
+- [ ] **步骤 4:接口注释同步**
+
+`HealthPlanService.java` 第 36 行注释:
+
+```java
+/** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
+```
+
+改为:
+
+```java
+/** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
+```
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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.deployPlan` 走 `taskService.createTask`,但 `createSingleTask` 不设 sourceType/sourceId,任务无法溯源。
+
+- [ ] **步骤 1:CreateTaskDTO 新增字段**
+
+`CreateTaskDTO.java` 末尾(`minDurationSeconds` 之后)新增:
+
+```java
+// 来源类型: 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` 之后)新增:
+
+```java
+// 来源类型透传(方案/模板/联动生成任务用于溯源)
+task.setSourceType(dto.getSourceType());
+task.setSourceId(dto.getSourceId());
+```
+
+- [ ] **步骤 3:deployPlan 补设 sourceType**
+
+`TrainingPlanService.deployPlan` 中,`CreateTaskDTO dto` 创建后(第 149-155 行之间)新增:
+
+```java
+dto.setSourceType("training_plan");
+dto.setSourceId(planId);
+```
+
+- [ ] **步骤 4:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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_plans` 和 `task_plan_instances` 的数量,若 >0 需人工确认。
+
+- [ ] **步骤 1:添加迁移 296(健康方案枚举修正 + 冲突检测)**
+
+在迁移 295 块之后添加:
+
+```java
+// 迁移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(认知训练方案枚举补设)**
+
+```java
+// 迁移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**
+
+```bash
+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:编写单元测试**
+
+```java
+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**
+
+```bash
+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` 返回类型(`TaskPlanInstance` → `Map`),需确认前端调用方;任务 10 测试依赖 JUnit5/Mockito 是否在 pom 中,缺失则降级为编译验证。

+ 181 - 0
docs/superpowers/specs/2026-09-04-plan-to-task-unification-design.md

@@ -0,0 +1,181 @@
+# 方案转任务统一设计(Plan → Task Unification)
+
+**优先级:** P0
+**预计工时:** 后端 3d + 迁移 1d
+**状态:** 设计待确认
+**日期:** 2026-09-04
+**关联文档:** `2026-09-04-task-system-unification-design.md`(统一任务系统总设计,本设计为其"方案转任务"子模块深化)
+
+## 1. 背景与问题
+
+系统存在 **4 套并行的"方案转任务"体系**,各自独立演进,`source_type` 语义混乱:
+
+| # | 方案体系 | 方案表 | 任务来源 | source_type | source_id 指向 | 状态机 | 生成方法 |
+|---|---------|--------|---------|-------------|----------------|--------|---------|
+| 1 | 套餐方案 | `task_plan_instances` | `task_plan_items`(镜像) | `plan` | `task_plan_instances.id` | active(无审核) | `TaskPlanService.generatePlanTasks` |
+| 2 | 测评方案 | `task_plan_instances` | `task_template_items`(直读) | `plan` | `task_plan_instances.id` | generated→reviewed→active | `PlanActivationService.activate` |
+| 3 | 健康方案 | `health_plans` | 方案文本正则解析 / plan_json | `plan` | `health_plans.id` | draft→pending_review→published | `HealthPlanServiceImpl.generateDailyTasksFromPlan` |
+| 4 | 认知训练方案 | `training_plans` | `training_plan_items` | **未设** | — | draft→published | `TrainingPlanService.deployPlan` |
+
+### 核心矛盾
+
+**`source_type='plan'` 被 3 套体系共用,但 `source_id` 指向 3 张不同的表**(`task_plan_instances` / `health_plans` / 无)。导致:
+
+1. `ProblemCompletionService`(第 106-110 行)按 `source_type='plan'` + `source_id IN (planIds)` 统计完成度时,`planIds` 来自 `task_plan_instances`,但健康方案任务也标 `source_type='plan'`,若 `source_id` 撞上 `task_plan_instances.id` 会被误统计。
+2. `HealthPlanServiceImpl.getPlanTasks`(第 196 行)按 `source_type='plan'` + `source_id=healthPlanId` 查任务,可能误捞套餐方案任务。
+3. 认知训练方案(第 4 套)**未设 sourceType**,`deployPlan` 走 `taskService.createTask` 后任务无法溯源。
+4. 套餐方案(第 1 套)与测评方案(第 2 套)虽同表,但任务来源不同(`task_plan_items` vs `task_template_items`)、deadline 语义不同(方案结束日 vs 当天 23:59:59)、`familyMemberId` 一个设一个不设。
+
+## 2. 范围决策(已与用户确认)
+
+| 决策项 | 结论 |
+|--------|------|
+| 覆盖范围 | **全部 4 套**方案体系 |
+| source_type 策略 | **拆分枚举**:新增 `health_plan` / `training_plan`,`source_type` 与 `source_id` 一一对应 |
+| 套餐/测评方案 | 保留 `plan` 枚举(同表 `task_plan_instances`),收敛为单一任务生成器 |
+| 任务来源 | 套餐/测评统一从 `task_template_items` 读取(`task_plan_items` 降级为审计快照) |
+| deadline 语义 | 统一为**当天 23:59:59**(对齐 activate 现有语义) |
+| `familyMemberId` | 统一设置 |
+| 状态机 | 套餐/测评统一 `generated → reviewed → active` |
+| 前端家长端 | **本次不做**(由其他进程负责) |
+
+## 3. source_type 枚举设计
+
+`tasks.source_type` 最终枚举(在总设计文档第 3 节基础上扩展):
+
+| source_type | 含义 | source_id 指向 |
+|-------------|------|----------------|
+| `self` | 用户创建给自己 | — |
+| `assigned` | 他人分配(家庭/规划师) | — |
+| `plan` | 套餐/测评方案实例化 | `task_plan_instances.id` |
+| `health_plan` | **健康方案实例化(新增)** | `health_plans.id` |
+| `training_plan` | **认知训练方案实例化(新增)** | `training_plans.id` |
+| `onboarding` | 新人引导 | — |
+| `ai` | AI 对话生成 | conversation_id |
+| `template` | 模板快速创建 | template_id |
+| `article` | 文章阅读联动 | article_id |
+| `activity` | 活动签到联动 | activity_id |
+| `purchase` | 购买/消费联动 | — |
+
+> **关键收益**:拆分后 `source_type` 与 `source_id` 一一对应,`ProblemCompletionService` 只查 `source_type='plan'` 天然隔离健康方案任务,无需额外过滤。
+
+## 4. 数据模型变更
+
+### 4.1 `tasks` 表
+
+`source_type` 列当前为 `VARCHAR(20)`,需扩展注释说明新增枚举(无需改列类型,20 足够容纳 `training_plan`)。**无 DDL 变更**,仅语义扩展。
+
+### 4.2 `task_plan_instances` 表(补齐缺失列)
+
+`TaskPlanInstance` 实体已声明以下字段,但 `schema.sql` 建表语句缺失,需补齐(否则 `review`/`activate` 写这些字段会 `Unknown column` 报错):
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `source` | VARCHAR(20) | manual/assessment/template |
+| `source_result_id` | BIGINT | 关联测评结果 |
+| `remark` | TEXT | 方案备注 |
+| `reviewed_by` | BIGINT | 审核人 |
+| `reviewed_at` | DATETIME | 审核时间 |
+| `review_comment` | TEXT | 审核意见 |
+| `confirmed_by` | BIGINT | 确认人 |
+| `confirmed_at` | DATETIME | 确认时间 |
+| `activated_at` | DATETIME | 激活时间 |
+
+### 4.3 `task_plan_items` 表
+
+**降级为审计快照**:保留 `applyPackage` 时的写入,不再作为任务生成来源。不物理删除(保留只读备份,符合总设计第 4.4 节)。
+
+## 5. 后端改造
+
+### 5.1 单一任务生成器(核心)
+
+在 `TaskPlanService` 新增统一方法,作为套餐/测评方案"方案→任务"的**唯一**入口:
+
+```java
+/**
+ * 统一方案任务生成器:套餐/测评方案激活时调用。
+ * 幂等:sourceType=plan & sourceId=planId 已存在任务则跳过。
+ * 任务来源:task_template_items(按 sort_order)。
+ * deadline:当天 23:59:59;familyMemberId:统一设置。
+ */
+@Transactional
+public int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId)
+```
+
+`PlanActivationService.activate` 和 `ProblemPackageController.confirm` 都改为调用它,消除 `generatePlanTasks` 与 `activate` 的分裂。
+
+### 5.2 状态机收敛(套餐/测评)
+
+| 入口 | 改造前 | 改造后 |
+|------|--------|--------|
+| `applyPackage` | 直接建 `active` | 建 `generated` |
+| `review` | 要求 `generated` | 不变(`generated → reviewed/rejected`) |
+| `activate` | 要求 `reviewed` | 不变(`reviewed → active`),内部改用统一生成器 |
+| `confirm`(家长自助) | 建 active + generatePlanTasks | 建 `generated` → 直接置 `active`(自助跳过审核)+ 统一生成器 |
+
+### 5.3 健康方案(第 3 套)
+
+- `HealthPlanServiceImpl.generateDailyTasksFromPlan` 中 `task.setSourceType("plan")` → `task.setSourceType("health_plan")`
+- `getPlanTasks` 查询条件 `source_type='plan'` → `source_type='health_plan'`
+- 幂等检查(第 367-373 行)同步改为 `health_plan`
+
+### 5.4 认知训练方案(第 4 套)
+
+- `TrainingPlanService.deployPlan` 中 `taskService.createTask` 后,补设 `sourceType="training_plan"` + `sourceId=planId`
+- 需在 `CreateTaskDTO` 增加 `sourceType`/`sourceId` 字段,`TaskService.createSingleTask` 透传
+
+### 5.5 幂等与空壳修复
+
+- `MarketController.purchasePackage`(入口3):补幂等(同家庭同套餐已有实例则复用)+ 补调任务生成,消除"active 但零任务"空壳
+- `TaskPlanService.cancelPlan`:取消时级联清理 `sourceType='plan' & sourceId=planId` 的未完成任务(pending 状态)
+
+## 6. 数据迁移
+
+在 `DatabaseInitializer.runMigrations()` 新增迁移:
+
+1. **补齐 `task_plan_instances` 缺失列**(`ensureColumn` 逐个添加,见 4.2)
+2. **存量健康方案任务改枚举**:
+   ```sql
+   UPDATE tasks SET source_type = 'health_plan'
+   WHERE source_type = 'plan'
+     AND source_id IN (SELECT id FROM health_plans);
+   ```
+3. **存量认知训练方案任务补枚举**:
+   ```sql
+   UPDATE tasks SET source_type = 'training_plan'
+   WHERE source_id IN (SELECT task_id FROM training_plan_tasks);
+   ```
+4. **同步 schema.sql**:`task_plan_instances` 建表语句补齐缺失列
+
+> **注意**:迁移 2/3 依赖 `health_plans` / `training_plan_tasks` 表存在,需先确认表存在再执行(幂等 try-catch)。
+
+## 7. 测试
+
+新增 `TaskPlanServiceTest`(或集成测试)覆盖:
+
+- [ ] 幂等:重复生成不重复建任务
+- [ ] 状态机:`generated` 不能直接 activate、`reviewed` 才能 activate
+- [ ] 空壳修复:purchasePackage 后必有任务
+- [ ] cancelPlan 级联清理未完成任务
+- [ ] 健康方案任务 `source_type='health_plan'`,`ProblemCompletionService` 不误统计
+- [ ] 认知训练方案任务 `source_type='training_plan'` 可溯源
+
+## 8. 范围边界(明确不做)
+
+| 不做 | 原因 |
+|------|------|
+| 前端家长端 plans 页面 | 其他进程负责 |
+| 健康方案/认知训练方案状态机统一 | 各自状态机已独立成熟,本次只统一 source_type 溯源 |
+| `task_plan_items` 物理删除 | 保留只读备份 |
+| 家庭挑战/五维打卡合并 | 总设计文档已明确本次不合并 |
+
+## 9. 验收标准
+
+- [ ] `tasks.source_type` 支持 `health_plan` / `training_plan` 新枚举
+- [ ] 套餐/测评方案统一走 `generateTasksFromTemplate`,deadline=当天 23:59:59,familyMemberId 统一设置
+- [ ] 套餐/测评状态机统一 `generated → reviewed → active`
+- [ ] 健康方案任务 `source_type='health_plan'`,`ProblemCompletionService` 不再误统计
+- [ ] 认知训练方案任务 `source_type='training_plan'` 可溯源
+- [ ] `task_plan_instances` 缺失列补齐,`review`/`activate` 不再 `Unknown column`
+- [ ] 存量数据迁移完成(健康/认知训练任务枚举修正)
+- [ ] 通过 `mvn clean compile` 编译验证