Преглед изворни кода

docs: 健康方案级联删除实现计划(6 任务 TDD)

iwt пре 1 недеља
родитељ
комит
266e083ed2
1 измењених фајлова са 637 додато и 0 уклоњено
  1. 637 0
      docs/superpowers/plans/2026-10-17-health-plan-deletion.md

+ 637 - 0
docs/superpowers/plans/2026-10-17-health-plan-deletion.md

@@ -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 方案后任务中心不再出现其任务,次日不复活) | 用户手动验证 | [ ] |