# 方案转任务统一(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() .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 items = templateItemMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() .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 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 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() .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 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 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> 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 applied = planService.applyAndActivate(dto, familyId, userId); return Result.success(applied); } ``` > 返回类型从 `Result` 改为 `Result>`(含 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 pendingTasks = taskMapper.selectList( new LambdaQueryWrapper() .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`(planId + taskCount)在任务 4/5 消费方式一致。 **4. 风险提示:** 任务 5 改了 `purchasePackage` 返回类型(`TaskPlanInstance` → `Map`),需确认前端调用方;任务 10 测试依赖 JUnit5/Mockito 是否在 pom 中,缺失则降级为编译验证。