|
|
@@ -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 中,缺失则降级为编译验证。
|