面向 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 |
统一生成器幂等/状态机单元测试 | 创建 |
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 报错。
在 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 行附近),自动忽略"列已存在"异常。
将 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;
运行: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审核/确认/激活列"
TaskPlanService 统一任务生成器 generateTasksFromTemplate文件:
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.javacfc-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。
在 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.TaskTemplateItem、java.util.Calendar(TaskTemplateItem 已 import,Calendar 需确认)。
TaskPlanServiceInterface.java 新增:
int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId);
运行: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"
PlanActivationService.activate 改用统一生成器文件:
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java背景: activate 现有引导任务 + 模板直读逻辑与统一生成器重复,且模板直读逻辑(不设 familyMemberId 的旧版)与统一生成器矛盾。改为直接调用统一生成器,删除重复代码。
注入 TaskPlanService,activate 方法体替换为:
@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 若无他用可保留以防其他方法引用,编译检查为准)。
运行: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改用统一任务生成器,删除重复模板生成逻辑"
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.javacfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java背景: applyPackage 直接建 active(无审核流程),confirm 后调 generatePlanTasks。改造后 applyPackage 建 generated,confirm 走 applyAndActivate(自助跳过审核置 active + 统一生成)。
TaskPlanService.applyPackage 第 82 行:
instance.setStatus("active");
改为:
instance.setStatus("generated"); // 状态机: generated → reviewed → active(见统一设计)
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.HashMap、java.util.Map(确认 TaskPlanService 现有 import)。
TaskPlanServiceInterface.java 新增:
Map<String, Object> applyAndActivate(ApplyPackageDTO dto, Long familyId, Long userId);
TaskPlanInstance findActiveByFamilyAndPackage(Long familyId, Long packageId);
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。
运行: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"
MarketController.purchasePackage 幂等 + 空壳修复文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/MarketController.java背景: purchasePackage 只 applyPackage 建 active 实例但不生成任务("active 但零任务"空壳),且可无限创建同套餐实例。
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前端调用(见下方验证)。
运行:grep -rn "packages/.*/purchase\|purchasePackage" cfc-frontend/utils/api.js cfc-frontend/pages/ 2>/dev/null
预期:若前端依赖 data.id 或 data.name,需同步适配(本次范围含前端小改动;若前端完全未调用,跳过)。
运行: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空壳"
TaskPlanService.cancelPlan 级联清理文件:
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java背景: cancelPlan 只改实例状态,已生成的 sourceType='plan' 任务仍可执行、仍参与 ProblemCompletionService 完成度统计。
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;
}
运行: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级联清理方案未完成任务"
source_type → health_plan文件:
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.javacfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java(注释同步)背景: 健康方案与套餐/测评方案共用 source_type='plan' 但 source_id 指向 health_plans,与 task_plan_instances ID 域冲突,导致 ProblemCompletionService 误统计。拆分枚举。
HealthPlanServiceImpl.generateDailyTasksFromPlan 第 404 行:
task.setSourceType("plan");
改为:
task.setSourceType("health_plan");
HealthPlanServiceImpl.getPlanTasks 第 196 行:
wrapper.eq("source_type", "plan")
改为:
wrapper.eq("source_type", "health_plan")
generateDailyTasksFromPlan 第 369 行(幂等 exists 检查)和第 378 行(existingBuy 查询):
.eq("source_type", "plan")
改为:
.eq("source_type", "health_plan")
HealthPlanService.java 第 36 行注释:
/** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
改为:
/** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
运行: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域冲突"
training_plan文件:
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.javacfc-backend/src/main/java/com/etotem/cfc/service/TaskService.javacfc-backend/src/main/java/com/etotem/cfc/service/TrainingPlanService.java背景: TrainingPlanService.deployPlan 走 taskService.createTask,但 createSingleTask 不设 sourceType/sourceId,任务无法溯源。
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;
TaskService.createSingleTask 末尾(第 288 行 setMinDurationSeconds 之后)新增:
// 来源类型透传(方案/模板/联动生成任务用于溯源)
task.setSourceType(dto.getSourceType());
task.setSourceId(dto.getSourceId());
TrainingPlanService.deployPlan 中,CreateTaskDTO dto 创建后(第 149-155 行之间)新增:
dto.setSourceType("training_plan");
dto.setSourceId(planId);
运行: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可溯源"
文件:
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 需人工确认。
在迁移 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"
文件:
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,则此测试文件可能无法编译——运行下述命令确认,若依赖缺失则跳过本任务测试(标注原因),以编译验证替代。
运行: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 作为验证。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行: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 返回类型(TaskPlanInstance → Map),需确认前端调用方;任务 10 测试依赖 JUnit5/Mockito 是否在 pom 中,缺失则降级为编译验证。