|
@@ -0,0 +1,637 @@
|
|
|
|
|
+# 健康方案级联删除 实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
|
|
+
|
|
|
|
|
+**目标:** 已生成的健康方案可删除,删除时同步删除该方案产生的全部任务(周期模板、一次性任务、每日实例),删除前由确认弹窗向用户明示删除范围,用户确认后才执行。
|
|
|
|
|
+
|
|
|
|
|
+**架构:** Service 层 `deletePlanCascade(planId, familyId)` 在一个 `@Transactional` 方法内完成「删每日实例(template_id)→ 删直接任务(source_id)→ 删方案」三级级联;用户端与管理端各暴露一个 `@PostMapping` 删除端点;小程序 `plan-list.vue` 与 Web `HealthPlanManage.vue` 提供删除按钮,均以 `uni.showModal` / `$confirm` 做删除前确认。无 schema 变更,不需要数据库迁移。
|
|
|
|
|
+
|
|
|
|
|
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus + JUnit 5/Mockito(后端测试,参考 `unit/HealthPlanReviewGuardTest.java` 的反射注入风格);uni-app 小程序(Vue 2 Options API);cfc-web Vue 2 + Element UI。
|
|
|
|
|
+
|
|
|
|
|
+**设计规格:** `docs/superpowers/specs/2026-10-17-health-plan-deletion-design.md`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 职责 | 任务 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| `cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java` | 级联删除单元测试(新建) | 任务 1 |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java` | Service 接口 +`deletePlanCascade` 声明 | 任务 1 |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` | 级联删除实现(`@Transactional`) | 任务 1 |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java` | 用户端 `POST /api/health/plan/delete` | 任务 2 |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java` | 管理端 `POST /api/admin/health-plan/delete` | 任务 2 |
|
|
|
|
|
+| `docs/superpowers/api/API_REFERENCE.md` | 新增 4.49 节记录接口 | 任务 3 |
|
|
|
|
|
+| `cfc-frontend/utils/api.js` | 小程序 API 封装 `deleteHealthPlan` | 任务 4 |
|
|
|
|
|
+| `cfc-frontend/pages/health/plan-list.vue` | 删除按钮 + 确认弹窗 + 刷新 | 任务 4 |
|
|
|
|
|
+| `cfc-web/src/api/healthPlan.js` | Web API 封装 `adminDeleteHealthPlan` | 任务 5 |
|
|
|
|
|
+| `cfc-web/src/views/admin/HealthPlanManage.vue` | 删除按钮 + `$confirm` + 刷新 | 任务 5 |
|
|
|
|
|
+| `docs/superpowers/PROJECT-OVERVIEW.md` | 版本/状态行追加条目 | 任务 6 |
|
|
|
|
|
+
|
|
|
|
|
+**删除边界回顾**(实现时不得越界):
|
|
|
|
|
+
|
|
|
|
|
+- 删:`health_plans` 方案行;`tasks` 中 `source_type='health_plan' AND source_id=planId`(周期模板 + 一次性任务 + 购买锚点);`tasks` 中 `template_id IN 上述任务ID`(每日实例——**实例不带 `source_id`,只透传 `template_id`,漏删会留孤儿任务且次日继续生成**)。
|
|
|
|
|
+- 不删:`points_log`(积分保留不撤回)、`task_executions` / `growth_task_log`(执行历史留痕)。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 1:后端 Service 层级联删除(TDD)
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 创建:`cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:编写失败的测试**
|
|
|
|
|
+
|
|
|
|
|
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java`(测试风格对齐同目录 `HealthPlanReviewGuardTest.java`:Mockito + 反射注入字段,无 Spring 上下文):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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.InOrder;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+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");
|
|
|
|
|
+ // 顺序必须:先删实例 → 再删直接任务 → 最后删方案
|
|
|
|
|
+ InOrder inOrder = inOrder(taskMapper, mapper);
|
|
|
|
|
+ inOrder.verify(taskMapper).delete(any(Wrapper.class));
|
|
|
|
|
+ inOrder.verify(taskMapper).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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=HealthPlanDeleteCascadeTest -q`
|
|
|
|
|
+预期:**编译失败**(`deletePlanCascade` 方法不存在),错误 `cannot find symbol: method deletePlanCascade`。失败原因正确(接口未声明)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:在 Service 接口声明方法**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java` 末尾(`generateFromSelfCheck` 声明之后、类闭合括号之前)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 删除健康方案并级联删除其产生的全部任务(一个事务内):
|
|
|
|
|
+ * 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);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+确认接口头部已有 `java.util.Map` 导入(第 8 行 `import java.util.Map;` 已存在,无需新增)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:编写最少实现代码**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` 的 `getPlanTasks` 方法之后(第 201 行 `}` 之后)插入实现:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+
|
|
|
|
|
+ @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;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+同时在该类 import 区追加(`import org.springframework.stereotype.Service;` 附近):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+依赖核对:`QueryWrapper`(第 3 行已导入)、`Task`/`HealthPlan`/`List`/`HashMap`/`HashMap`(第 2、7-31 行已导入)、`log`(`@Slf4j`)均已就绪,无需其他新增。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=HealthPlanDeleteCascadeTest -q`
|
|
|
|
|
+预期:**BUILD SUCCESS**,5 个测试全部 PASS(级联删除含顺序验证、无任务方案、IDOR 拒绝、方案不存在、管理端跳过校验)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend
|
|
|
|
|
+git add src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java \
|
|
|
|
|
+ src/main/java/com/etotem/cfc/service/HealthPlanService.java \
|
|
|
|
|
+ src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
|
|
|
|
|
+git commit -m "feat(health-plan): 方案级联删除 Service 层(含每日实例二次删除)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 2:后端 Controller 层两个删除端点
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:用户端删除端点**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java` 的 `planTasks` 方法之后(第 158 行 `}` 之后、"获取可用规划师列表"注释之前)插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+
|
|
|
|
|
+ @Operation(summary = "删除健康方案(级联删除方案任务,含每日实例)")
|
|
|
|
|
+ @PostMapping("/delete")
|
|
|
|
|
+ public Result<Map<String, Object>> deletePlan(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId) {
|
|
|
|
|
+ Long planId = ParamUtils.getLong(params.get("planId"));
|
|
|
|
|
+ if (planId == null) return Result.error("planId不能为空");
|
|
|
|
|
+ Map<String, Object> result = healthPlanService.deletePlanCascade(planId, familyId);
|
|
|
|
|
+ if (result == null) {
|
|
|
|
|
+ return Result.error("无权删除该方案");
|
|
|
|
|
+ }
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+校验:`Map`(第 15 行 `import java.util.Map;`)、`ParamUtils`(第 16 行)、`@PostMapping`(第 11 行通配导入)均已导入;路由 `/delete` 与现有 12 条路由无冲突(已查重)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:管理端删除端点**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java` 的 `detail` 方法之后(第 61 行 `}` 之后)插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+
|
|
|
|
|
+ @Operation(summary = "删除健康方案(级联删除方案任务,含每日实例)")
|
|
|
|
|
+ @PostMapping("/delete")
|
|
|
|
|
+ public Result<Map<String, Object>> deletePlan(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
|
|
+ if (!"admin".equals(role) && !"teacher".equals(role) && !"butler".equals(role)
|
|
|
|
|
+ && !"nutritionist".equals(role) && !"operator".equals(role)) {
|
|
|
|
|
+ return Result.error("无权限");
|
|
|
|
|
+ }
|
|
|
|
|
+ Long planId = ParamUtils.getLong(params.get("planId"));
|
|
|
|
|
+ if (planId == null) {
|
|
|
|
|
+ return Result.error("planId不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 管理端跨家庭删除,跳过 familyId 归属校验(familyId 传 null)
|
|
|
|
|
+ Map<String, Object> result = healthPlanService.deletePlanCascade(planId, null);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn clean compile -q`
|
|
|
|
|
+预期:**BUILD SUCCESS**,无编译错误。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend
|
|
|
|
|
+git add src/main/java/com/etotem/cfc/controller/HealthPlanController.java \
|
|
|
|
|
+ src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java
|
|
|
|
|
+git commit -m "feat(health-plan): 新增方案删除接口(用户端 + 管理端)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 3:API 文档同步
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:追加 4.49 节**
|
|
|
|
|
+
|
|
|
|
|
+在 `docs/superpowers/api/API_REFERENCE.md` 第 1670-1672 行(`*文档更新:2026-10-09(指标统一层新增...)*` 与 `*文档最后更新:2026-10-04*` 之间)插入:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+### 4.49 健康方案删除(`/api/health/plan/delete`、`/api/admin/health-plan/delete`)
|
|
|
|
|
+
|
|
|
|
|
+已生成的健康方案可删除,**级联删除其产生的全部任务**(含每日实例)。
|
|
|
|
|
+
|
|
|
|
|
+| 端点 | 权限 | 请求 | 返回 |
|
|
|
|
|
+|---|---|---|---|
|
|
|
|
|
+| `POST /api/health/plan/delete` | 登录用户(JWT) | `{planId}` | `{planId, taskCount}` |
|
|
|
|
|
+| `POST /api/admin/health-plan/delete` | admin/teacher/butler/nutritionist/operator | `{planId}` | `{planId, taskCount}` |
|
|
|
|
|
+
|
|
|
|
|
+- **级联范围**:`health_plans` 方案行 + `tasks` 中 `source_type='health_plan' AND source_id=planId` 的直接任务(周期模板/一次性任务/购买锚点)+ `tasks` 中 `template_id IN 直接任务ID` 的每日实例(`RepeatTaskGenerator` 生成,只透传 `template_id` 不带 `source_id`,必须二次删除,否则任务删后次日仍被再生产)。
|
|
|
|
|
+- **保留不删**:`points_log`(积分流水,已完成任务的积分保留不撤回)、`task_executions` / `growth_task_log`(执行历史)。
|
|
|
|
|
+- **事务**:`HealthPlanServiceImpl.deletePlanCascade` 标注 `@Transactional`,任一步失败整体回滚,不留无方案的孤儿任务。
|
|
|
|
|
+- **IDOR**:用户端接口从 `@RequestAttribute("familyId")` 取家庭做归属校验,方案不属于该家庭返回「无权删除该方案」;管理端传 `familyId=null` 跳过归属校验。
|
|
|
|
|
+- **状态**:任何状态(draft/pending_review/published/rejected)均可删除;前端以确认弹窗兜底提醒。
|
|
|
|
|
+
|
|
|
|
|
+*文档更新:2026-10-17(健康方案级联删除,见 `specs/2026-10-17-health-plan-deletion-design.md`)*
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/superpowers/api/API_REFERENCE.md
|
|
|
|
|
+git commit -m "docs(api): 健康方案删除接口记录(4.49)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 4:小程序端删除按钮 + 确认弹窗
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-frontend/utils/api.js`
|
|
|
|
|
+- 修改:`cfc-frontend/pages/health/plan-list.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:API 封装**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/utils/api.js` 第 2582 行(`getHealthPlanTasks` 之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+export const deleteHealthPlan = (data) => request('/api/health/plan/delete', 'POST', data)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:plan-list.vue 模板加删除按钮**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/pages/health/plan-list.vue` 第 35-37 行,`plan-title` 文本与 `plan-status` 视图之间插入删除按钮(`@click.stop` 阻止冒泡进入详情,符合小程序事件规范):
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <text class="plan-title">{{ plan.goal || plan.memberName || '健康方案' }}</text>
|
|
|
|
|
+ <text class="plan-delete" @click.stop="onDeletePlan(plan)">删除</text>
|
|
|
|
|
+ <view class="plan-status" :class="'status-' + plan.status">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:样式**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/pages/health/plan-list.vue` 的 `.plan-title` 样式块之后(第 210 行 `}` 之后)插入:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+
|
|
|
|
|
+.plan-delete {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #C62828;
|
|
|
|
|
+ padding: 8rpx 16rpx;
|
|
|
|
|
+ margin-right: 16rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:methods 实现删除交互**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/pages/health/plan-list.vue` 的 `goDetail` 方法之后(第 90 行 `},` 之后)插入:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ onDeletePlan(plan) {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ uni.showModal({
|
|
|
|
|
+ title: '删除方案',
|
|
|
|
|
+ content: '删除后不可恢复:该方案及其已生成的所有任务(含每日生成的实例)将一并删除。确认删除?',
|
|
|
|
|
+ confirmText: '删除',
|
|
|
|
|
+ confirmColor: '#C62828',
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ if (!res.confirm) return
|
|
|
|
|
+ deleteHealthPlan({ planId: plan.id }).then(function(r) {
|
|
|
|
|
+ if (r && r.code === 200) {
|
|
|
|
|
+ var count = (r.data && r.data.taskCount) || 0
|
|
|
|
|
+ uni.showToast({ title: '已删除方案及 ' + count + ' 条任务', icon: 'none' })
|
|
|
|
|
+ self.loadPlans()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: (r && r.message) || '删除失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function(err) {
|
|
|
|
|
+ console.error('删除方案失败', err)
|
|
|
|
|
+ uni.showToast({ title: '删除失败', icon: 'none' })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:引入 API 函数**
|
|
|
|
|
+
|
|
|
|
|
+将 `cfc-frontend/pages/health/plan-list.vue` 第 50 行的 import 改为:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { getHealthPlanList, deleteHealthPlan } from '@/utils/api.js'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:语法校验(不打包)**
|
|
|
|
|
+
|
|
|
|
|
+`node --check` 默认按 CommonJS 解析 `.js`,而本文件是 ESM(`import`/`export`)会误报语法错——统一先落成 `.mjs` 再校验:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-frontend
|
|
|
|
|
+node -e "var fs=require('fs');var s=fs.readFileSync('pages/health/plan-list.vue','utf8');fs.writeFileSync('/tmp/plan-list.check.mjs', s.match(/<script>([\s\S]*?)<\/script>/)[1]);"
|
|
|
|
|
+node --check /tmp/plan-list.check.mjs
|
|
|
|
|
+cp utils/api.js /tmp/frontend-api.check.mjs && node --check /tmp/frontend-api.check.mjs && echo "SYNTAX OK"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期:两条 `node --check` 均无输出即通过,最后输出 `SYNTAX OK`。
|
|
|
|
|
+再运行前端重复调用审计(规则:本页 `deleteHealthPlan` 为用户主动触发,不违反首次加载去重规则):
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+node scripts/audit-duplicate-api-calls.js
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期:退出码 0,无 ERROR 级违规。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-frontend
|
|
|
|
|
+git add utils/api.js pages/health/plan-list.vue
|
|
|
|
|
+git commit -m "feat(health): 方案列表支持删除(确认弹窗 + 级联删除任务)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 5:Web 管理端删除按钮 + 确认弹窗
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-web/src/api/healthPlan.js`
|
|
|
|
|
+- 修改:`cfc-web/src/views/admin/HealthPlanManage.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:API 封装**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-web/src/api/healthPlan.js` 末尾追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+
|
|
|
|
|
+/** 管理端:删除健康方案(级联删除方案任务,含每日实例) */
|
|
|
|
|
+export function adminDeleteHealthPlan(data) {
|
|
|
|
|
+ return request({
|
|
|
|
|
+ url: '/api/admin/health-plan/delete',
|
|
|
|
|
+ method: 'post',
|
|
|
|
|
+ data: data
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:操作列加删除按钮**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-web/src/views/admin/HealthPlanManage.vue` 第 59-63 行,操作列(`width="100"` 改 `"140"` 容纳两按钮)内,`详情` 按钮后插入:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <el-table-column label="操作" width="140">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <el-button size="mini" type="primary" @click="showDetail(row)">详情</el-button>
|
|
|
|
|
+ <el-button size="mini" type="danger" style="margin-left:6px" @click="onDeletePlan(row)">删除</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:import 与方法**
|
|
|
|
|
+
|
|
|
|
|
+将 `cfc-web/src/views/admin/HealthPlanManage.vue` 第 131 行改为:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { adminListHealthPlans, adminGetHealthPlanDetail, adminDeleteHealthPlan } from '@/api/healthPlan'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `handlePageChange` 方法之后(第 185 行 `},` 之后)插入:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ async onDeletePlan(row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.$confirm(
|
|
|
|
|
+ '删除后不可恢复:该方案及其已生成的所有任务(含每日生成的实例)将一并删除。确认删除?',
|
|
|
|
|
+ '删除方案',
|
|
|
|
|
+ { type: 'warning', confirmButtonText: '删除', confirmButtonClass: 'el-button--danger' }
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return // 用户取消
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await adminDeleteHealthPlan({ planId: row.id })
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ const count = (res.data && res.data.taskCount) || 0
|
|
|
|
|
+ this.$message.success('已删除方案及 ' + count + ' 条任务')
|
|
|
|
|
+ // 删除本页最后一条且不在第一页时回退一页,避免空页
|
|
|
|
|
+ if (this.plans.length === 1 && this.pageNum > 1) this.pageNum = this.pageNum - 1
|
|
|
|
|
+ this.loadPlans()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.$message.error(res.message || '删除失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error('删除失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:语法校验(不打包,ESM 需 .mjs)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-web
|
|
|
|
|
+node -e "var fs=require('fs');var s=fs.readFileSync('src/views/admin/HealthPlanManage.vue','utf8');fs.writeFileSync('/tmp/HealthPlanManage.check.mjs', s.match(/<script>([\s\S]*?)<\/script>/)[1]);"
|
|
|
|
|
+node --check /tmp/HealthPlanManage.check.mjs
|
|
|
|
|
+cp src/api/healthPlan.js /tmp/healthPlan.check.mjs && node --check /tmp/healthPlan.check.mjs && echo "SYNTAX OK"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期:两条 `node --check` 均无输出即通过,最后输出 `SYNTAX OK`。
|
|
|
|
|
+(说明:两文件均为 ESM,落为 `.mjs` 后 `--check` 只做语法解析、不解析模块路径,能覆盖语法错误;实际编译由 HBuilderX 打包期兜底。)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-web
|
|
|
|
|
+git add src/api/healthPlan.js src/views/admin/HealthPlanManage.vue
|
|
|
|
|
+git commit -m "feat(admin): 健康方案管理支持删除(确认弹窗 + 级联删除任务)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 6:PROJECT-OVERVIEW 同步 + 全局验证
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:状态行追加条目**
|
|
|
|
|
+
|
|
|
|
|
+在 `docs/superpowers/PROJECT-OVERVIEW.md` 第 7 行(饮食/睡眠/运动打卡条目)之后插入新行:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+**健康方案删除(✅ 全栈完成:后端级联删除接口含每日实例二次删除 + 小程序/管理端删除按钮 + 确认弹窗;见 `specs/2026-10-17-health-plan-deletion-design.md` 与 `plans/2026-10-17-health-plan-deletion.md`)**
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+同步将第 3 行 `**文档版本:** v2.17` 改为 `**文档版本:** v2.18`,第 4 行 `**日期:** 2026-10-09` 改为 `**日期:** 2026-10-17`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:全量回归验证(后端)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && mvn clean compile -q && mvn test -Dtest=HealthPlanDeleteCascadeTest -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期:`BUILD SUCCESS`,5 个测试 PASS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:前端审计**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+node scripts/audit-duplicate-api-calls.js
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+预期:退出码 0。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/superpowers/PROJECT-OVERVIEW.md
|
|
|
|
|
+git commit -m "docs(overview): 健康方案级联删除完成(v2.18)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 验证清单(验收标准)
|
|
|
|
|
+
|
|
|
|
|
+逐项对照规格第 1/2/3/8 节:
|
|
|
|
|
+
|
|
|
|
|
+| # | 验收项 | 验证方式 | 状态 |
|
|
|
|
|
+|---|---|---|---|
|
|
|
|
|
+| 1 | `deletePlanCascade` 一次性事务内完成「实例→直接任务→方案」三级删除 | `HealthPlanDeleteCascadeTest` 5 用例(InOrder 验证顺序 + taskCount 汇总) | [ ] |
|
|
|
|
|
+| 2 | `familyId` 不匹配拒绝删除且零数据变动(IDOR) | 测试用例「方案不属于该家庭返回null且不删任何数据」 | [ ] |
|
|
|
|
|
+| 3 | 用户端 `POST /api/health/plan/delete` 返回 `{planId, taskCount}` | 编译 + 联调(HBuilderX 重打包后小程序点删除) | [ ] |
|
|
|
|
|
+| 4 | 管理端 `POST /api/admin/health-plan/delete` 五角色权限校验 | 编译 + 管理端联调 | [ ] |
|
|
|
|
|
+| 5 | 小程序删除前弹窗明示「方案及已生成任务(含每日实例)、不可恢复」,确认后才发请求 | 代码走查 `plan-list.vue` `uni.showModal` → `deleteHealthPlan` 顺序 | [ ] |
|
|
|
|
|
+| 6 | 删除成功后 toast 实际删除数并刷新列表 | 代码走查 `loadPlans()` 重载 | [ ] |
|
|
|
|
|
+| 7 | 无 schema 变更,未动 DatabaseInitializer | `git diff` 确认 `DatabaseInitializer.java` 无改动 | [ ] |
|
|
|
|
|
+| 8 | API 文档 4.49 节 + PROJECT-OVERVIEW 已同步 | `git show --stat` 两个提交含文档 | [ ] |
|
|
|
|
|
+| 9 | 全量编译通过 | `mvn clean compile` | [ ] |
|
|
|
|
|
+| 10 | 小程序/HBuilderX 人工验收(删除一个 published 方案后任务中心不再出现其任务,次日不复活) | 用户手动验证 | [ ] |
|