Просмотр исходного кода

feat(health-plan): 级联删除方案任务的 Service 层实现(任务 1/6,TDD 5 用例)

iwt 1 неделя назад
Родитель
Сommit
87fed30478

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -53,4 +53,15 @@ public interface HealthPlanService {
 
 
     /** 基于自检低分维度自动生成 draft 计划 */
     /** 基于自检低分维度自动生成 draft 计划 */
     Long generateFromSelfCheck(Long userId, Long checkId);
     Long generateFromSelfCheck(Long userId, Long checkId);
+
+    /**
+     * 删除健康方案并级联删除其产生的全部任务(一个事务内):
+     * 1. 直接任务:tasks.source_type='health_plan' AND source_id=planId(周期模板/一次性/购买锚点)
+     * 2. 每日实例:tasks.template_id IN 直接任务ID(RepeatTaskGenerator 生成,不带 source_id,必须二次删除)
+     * 3. 方案行:health_plans
+     *
+     * @param familyId 归属校验家庭ID;传 null 跳过(管理端跨家庭删除)
+     * @return {planId, taskCount: 直接+实例删除总数};方案不存在抛异常,归属不匹配返回 null
+     */
+    Map<String, Object> deletePlanCascade(Long planId, Long familyId);
 }
 }

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -20,6 +20,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.client.RestTemplate;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
@@ -200,6 +201,50 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return taskMapper.selectList(wrapper);
         return taskMapper.selectList(wrapper);
     }
     }
 
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> deletePlanCascade(Long planId, Long familyId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) {
+            throw new RuntimeException("方案不存在");
+        }
+        if (familyId != null && !familyId.equals(plan.getFamilyId())) {
+            // IDOR 防护:方案不属于该家庭,整体拒绝,不删任何数据
+            return null;
+        }
+
+        // 1. 查方案直接任务(周期模板 + 一次性任务 + 购买锚点)
+        List<Task> planTasks = taskMapper.selectList(new QueryWrapper<Task>()
+                .eq("source_type", "health_plan")
+                .eq("source_id", planId));
+        int deleted = 0;
+
+        // 2. 先删每日实例:实例只透传 template_id、不带 source_id,漏删会留孤儿任务且次日继续生成
+        if (planTasks != null && !planTasks.isEmpty()) {
+            List<Long> taskIds = new java.util.ArrayList<Long>();
+            for (Task t : planTasks) {
+                if (t != null && t.getId() != null) taskIds.add(t.getId());
+            }
+            if (!taskIds.isEmpty()) {
+                deleted += taskMapper.delete(new QueryWrapper<Task>().in("template_id", taskIds));
+            }
+        }
+
+        // 3. 删直接任务
+        deleted += taskMapper.delete(new QueryWrapper<Task>()
+                .eq("source_type", "health_plan")
+                .eq("source_id", planId));
+
+        // 4. 删方案行
+        healthPlanMapper.deleteById(planId);
+
+        log.info("方案级联删除完成 planId={}, taskCount={}", planId, deleted);
+        Map<String, Object> result = new HashMap<>();
+        result.put("planId", planId);
+        result.put("taskCount", deleted);
+        return result;
+    }
+
     @Override
     @Override
     public Long confirmPlan(Long planId) {
     public Long confirmPlan(Long planId) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
         HealthPlan plan = healthPlanMapper.selectById(planId);

+ 128 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java

@@ -0,0 +1,128 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HealthPlanDeleteCascadeTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper mapper;
+    private TaskMapper taskMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        mapper = mock(HealthPlanMapper.class);
+        taskMapper = mock(TaskMapper.class);
+        Field f = HealthPlanServiceImpl.class.getDeclaredField("healthPlanMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+    }
+
+    private HealthPlan planOf(Long id, Long familyId, String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus(status);
+        return p;
+    }
+
+    private Task taskOf(Long id) {
+        Task t = new Task();
+        t.setId(id);
+        return t;
+    }
+
+    @Test
+    void 级联删除_先删每日实例再删直接任务并删方案() {
+        when(mapper.selectById(5L)).thenReturn(planOf(5L, 10L, "published"));
+        // 直接任务:1 个周期模板 + 1 个一次性任务
+        when(taskMapper.selectList(any(Wrapper.class)))
+                .thenReturn(Arrays.asList(taskOf(101L), taskOf(102L)));
+        // 第 1 次 delete(实例)影响 3 行;第 2 次 delete(直接任务)影响 2 行
+        when(taskMapper.delete(any(Wrapper.class))).thenReturn(3, 2);
+
+        Map<String, Object> r = service.deletePlanCascade(5L, 10L);
+
+        assertEquals(5, ((Number) r.get("taskCount")).intValue(), "taskCount = 实例3 + 直接任务2");
+        // 两次 delete 的语义断言:第一次按 template_id 删每日实例,第二次按 source_type+source_id 删直接任务
+        ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(taskMapper, times(2)).delete(captor.capture());
+        List<Wrapper> wrappers = captor.getAllValues();
+        assertTrue(wrappers.get(0).getTargetSql().contains("template_id"),
+                "第一次删除必须按 template_id(每日实例),实际: " + wrappers.get(0).getTargetSql());
+        String secondSql = wrappers.get(1).getTargetSql();
+        assertTrue(secondSql.contains("source_type") && secondSql.contains("source_id"),
+                "第二次删除必须按 source_type+source_id(直接任务),实际: " + secondSql);
+        // 任务删除必须先于方案删除
+        InOrder inOrder = inOrder(taskMapper, mapper);
+        inOrder.verify(taskMapper, atLeastOnce()).delete(any(Wrapper.class));
+        inOrder.verify(mapper).deleteById(5L);
+    }
+
+    @Test
+    void 无任务的方案删除时只删方案不触发实例删除() {
+        when(mapper.selectById(7L)).thenReturn(planOf(7L, 10L, "draft"));
+        when(taskMapper.selectList(any(Wrapper.class))).thenReturn(Collections.<Task>emptyList());
+        when(taskMapper.delete(any(Wrapper.class))).thenReturn(0);
+
+        Map<String, Object> r = service.deletePlanCascade(7L, 10L);
+
+        assertEquals(0, ((Number) r.get("taskCount")).intValue());
+        verify(taskMapper, times(1)).delete(any(Wrapper.class)); // 只删直接任务(0 行),不删实例
+        verify(mapper).deleteById(7L);
+    }
+
+    @Test
+    void 方案不属于该家庭返回null且不删任何数据() {
+        when(mapper.selectById(5L)).thenReturn(planOf(5L, 10L, "published"));
+
+        assertNull(service.deletePlanCascade(5L, 20L), "familyId 不匹配必须整体拒绝");
+        verify(taskMapper, never()).delete(any(Wrapper.class));
+        verify(mapper, never()).deleteById(any());
+    }
+
+    @Test
+    void 方案不存在抛方案不存在() {
+        when(mapper.selectById(404L)).thenReturn(null);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.deletePlanCascade(404L, 10L));
+        assertEquals("方案不存在", ex.getMessage());
+        verify(mapper, never()).deleteById(any());
+    }
+
+    @Test
+    void 管理端删除跳过familyId归属校验() {
+        when(mapper.selectById(5L)).thenReturn(planOf(5L, 10L, "rejected"));
+        when(taskMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(taskOf(300L)));
+        when(taskMapper.delete(any(Wrapper.class))).thenReturn(1, 1);
+
+        Map<String, Object> r = service.deletePlanCascade(5L, null);
+
+        assertNotNull(r);
+        assertEquals(2, ((Number) r.get("taskCount")).intValue());
+        verify(mapper).deleteById(5L);
+    }
+}