Explorar o código

fix(task-plan): 最终审查6项修复——废弃旧方法/修正迁移296/补充状态机测试/更新注释

iwt hai 2 meses
pai
achega
6f5866cefc

+ 9 - 9
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10360,22 +10360,22 @@ public class DatabaseInitializer implements CommandLineRunner {
 
         // 迁移296: 健康方案任务 source_type 'plan' -> 'health_plan'(消除与 task_plan_instances 的 ID 域冲突)
         try {
-            // 冲突检测:source_id 同时存在于 health_plans 和 task_plan_instances 的任务(需人工确认)
+            // 冲突检测: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);
+                log.warn("迁移296: 检测到{}条冲突任务(source_id同时存在于health_plans和task_plan_instances),需人工确认;无冲突部分仍继续迁移", conflict);
             }
+            // 始终执行 UPDATE:仅迁移"存在于 health_plans 且不存在于 task_plan_instances"的任务(冲突部分不迁移,避免误改套餐任务)
+            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());
         }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java

@@ -98,7 +98,7 @@ public class ProblemCompletionService {
             return;
         }
 
-        // 2. 找这些方案生成的计划任务(source_type='plan')
+        // 2. 找这些方案生成的计划任务(source_type='plan',套餐/测评方案;健康方案已用 health_plan 隔离)
         List<Long> planIds = new ArrayList<>();
         for (TaskPlanInstance ins : instances) {
             planIds.add(ins.getId());

+ 9 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java

@@ -121,10 +121,13 @@ public class TaskPlanService implements TaskPlanServiceInterface {
 
     /**
      * 方案确认:将套餐计划实例生成可执行任务(B20 补全)
-     * 每个 plan item 生成一条 Task:memberOnly=1、sourceType=plan、sourceId=planId,
-     * 使 ProblemCompletionService 能按 (familyId, sourceType=plan) 统计完成度发奖。
-     * 幂等:同一计划已生成过任务则跳过。
+     *
+     * @deprecated 已被 {@link #generateTasksFromTemplate} 替代。
+     * 旧方法从 task_plan_items 读任务(已降级为审计快照),
+     * 新方法 generateTasksFromTemplate 从 task_template_items 读——两套数据源不一致,
+     * 若未来误调用会造成双轨。confirm 流程已改走 applyAndActivate → generateTasksFromTemplate。
      */
+    @Deprecated
     @Transactional
     public int generatePlanTasks(Long planId, Long familyId, Long userId) {
         TaskPlanInstance plan = instanceMapper.selectById(planId);
@@ -194,11 +197,12 @@ public class TaskPlanService implements TaskPlanServiceInterface {
             throw new IllegalStateException("方案不存在");
         }
         Long planId = plan.getId();
-        // 幂等:该方案已生成过任务则跳过
+        // 幂等:该方案已生成过任务则跳过(排除 cancelled,避免 cancelPlan 后重新激活时漏生成)
         Long existing = taskMapper.selectCount(
                 new LambdaQueryWrapper<Task>()
                         .eq(Task::getSourceType, "plan")
-                        .eq(Task::getSourceId, planId));
+                        .eq(Task::getSourceId, planId)
+                        .ne(Task::getStatus, "cancelled"));
         if (existing != null && existing > 0) {
             return 0;
         }

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     start_required TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)',
     start_count INT DEFAULT 0 COMMENT '已开始次数(累计)',
     min_duration_seconds INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)',
-    source_type VARCHAR(20) COMMENT '来源类型: manual/self/assigned/plan/training_plan/onboarding/ai/template/article/activity/purchase',
+    source_type VARCHAR(20) COMMENT '来源类型: manual/self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase',
     source_id BIGINT COMMENT '来源ID(如 training_plans.id / task_plan_instances.id)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

+ 45 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java

@@ -10,13 +10,18 @@ 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 com.etotem.cfc.dto.ApplyPackageDTO;
+import com.etotem.cfc.entity.TaskTemplatePackage;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.lang.reflect.Field;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
@@ -110,4 +115,44 @@ class TaskPlanServiceTest {
         assertEquals(true, ok);
         verify(taskMapper, times(1)).updateById(any(Task.class));
     }
+
+    @Test
+    void applyAndActivate_无active实例时新建并置active() {
+        ApplyPackageDTO dto = new ApplyPackageDTO();
+        dto.setPackageId(30L);
+        dto.setMemberId(20L);
+
+        // findActiveByFamilyAndPackage → null(无 active 实例)
+        when(instanceMapper.selectOne(any())).thenReturn(null);
+
+        // applyPackage 内部:packageService.getPackageById → 返回 mock package
+        TaskTemplatePackage pkg = new TaskTemplatePackage();
+        pkg.setId(30L);
+        pkg.setName("测试套餐");
+        pkg.setCycleDays(7);
+        when(packageService.getPackageById(30L)).thenReturn(pkg);
+        // templateItemMapper.selectList → 空列表(避免 NPE)
+        when(templateItemMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // instanceMapper.insert → 返回1,用 Answer 捕获实例并设 ID
+        TaskPlanInstance capturedInstance = new TaskPlanInstance();
+        when(instanceMapper.insert(any(TaskPlanInstance.class))).thenAnswer(invocation -> {
+            capturedInstance.setId(100L);
+            return 1;
+        });
+
+        // generateTasksFromTemplate → 幂等检查返回 0(无旧任务),生成 0 条模板任务
+        when(taskMapper.selectCount(any())).thenReturn(0L);
+        // buildTodayDeadline 用 Calendar,测试中不直接调用(private),但 generateTasksFromTemplate 内部会调用
+        // 由于 templateItemMapper 已 mock 返回空,generateTasksFromTemplate 只会生成引导任务(1条)
+        // 但引导任务也调 taskMapper.insert,需要 mock
+        when(taskMapper.insert(any(Task.class))).thenReturn(1);
+
+        Map<String, Object> result = service.applyAndActivate(dto, 10L, 99L);
+
+        assertNotNull(result);
+        assertEquals(100L, result.get("planId"));
+        assertEquals(1, result.get("taskCount")); // 引导任务 1 条
+        // 状态置 active:instanceMapper.updateById 被调用一次
+        verify(instanceMapper, times(1)).updateById(any(TaskPlanInstance.class));
+    }
 }