|
@@ -0,0 +1,186 @@
|
|
|
|
|
+# 健康方案删除(级联删除方案任务)设计
|
|
|
|
|
+
|
|
|
|
|
+- 日期:2026-10-17
|
|
|
|
|
+- 状态:已批准(设计评审通过,待实现)
|
|
|
|
|
+
|
|
|
|
|
+## 1. 背景与目标
|
|
|
|
|
+
|
|
|
|
|
+健康方案(`health_plans`)由 AI 生成,用户确认或规划师审核通过后发布,并自动分解为任务。
|
|
|
|
|
+目前方案**只能新增/编辑/审核,无法删除**。运营与家庭用户对失效、误建、重复生成的方案没有回收手段,
|
|
|
|
|
+且遗漏删除会让「每日任务」持续被 `RepeatTaskGenerator` 再生产出来。
|
|
|
|
|
+
|
|
|
|
|
+目标:
|
|
|
|
|
+
|
|
|
|
|
+1. 已生成的方案(任何状态)可删除;
|
|
|
|
|
+2. 删除方案时**同步删除该方案产生的全部任务数据**;
|
|
|
|
|
+3. 删除前必须提醒用户「方案 + 已生成任务(含每日实例)都会被删除且不可恢复」,用户确认后才执行。
|
|
|
|
|
+
|
|
|
|
|
+## 2. 范围
|
|
|
|
|
+
|
|
|
|
|
+| 项 | 决定 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| 方案类型 | 健康方案 `health_plans`(AI 生成 → 确认/审核发布 → 分解任务) |
|
|
|
|
|
+| 入口 | 小程序 `pages/health/plan-list.vue` 卡片删除按钮;Web 管理端 `src/views/admin/HealthPlanManage.vue` 操作列删除按钮 |
|
|
|
|
|
+| 规划师审核端 | 不做(规划师用「驳回」处理问题方案,不删除) |
|
|
|
|
|
+| 删除状态 | 任何状态(draft / pending_review / published / rejected)均可删除,由确认弹窗兜底提醒 |
|
|
|
|
|
+| 不做 | 套餐方案实例(`task_plan_instances`)、训练方案(`training_plans`)不在本期范围 |
|
|
|
|
|
+
|
|
|
|
|
+## 3. 数据关系与删除边界
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 方案任务的两级关联(核心约束)
|
|
|
|
|
+
|
|
|
|
|
+`HealthPlanServiceImpl.generateDailyTasksFromPlan()` 生成两类任务行,均标记
|
|
|
|
|
+`source_type='health_plan'`、`source_id=planId`:
|
|
|
|
|
+
|
|
|
|
|
+1. **直接任务**:周期任务模板(`is_template=1`)+ 一次性任务 + 购买锚点任务;
|
|
|
|
|
+2. **每日实例**:由 `RepeatTaskGenerator.createTaskFromTemplate()` 按日生成,**只透传
|
|
|
|
|
+ `template_id`,不带 `source_id`**(源码 `RepeatTaskGenerator.java` 第 284 行)。
|
|
|
|
|
+
|
|
|
|
|
+因此删除必须两步,否则模板删掉后每日实例变成孤儿任务、次日仍被生成:
|
|
|
|
|
+
|
|
|
|
|
+| 步骤 | SQL(示意) |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| ① 删每日实例 | `DELETE FROM tasks WHERE template_id IN (方案直接任务ID集合)` |
|
|
|
|
|
+| ② 删直接任务 | `DELETE FROM tasks WHERE source_type='health_plan' AND source_id=planId` |
|
|
|
|
|
+| ③ 删方案 | `DELETE FROM health_plans WHERE id=planId` |
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 明确不删(保留作历史)
|
|
|
|
|
+
|
|
|
|
|
+| 表 | 理由 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| `points_log`(积分流水) | 完成方案任务已得的积分保留,删任务 ≠ 撤回积分 |
|
|
|
|
|
+| `task_executions` / `growth_task_log` 等执行记录 | 历史留痕,`taskId` 字段引用悬空但仅作记录 |
|
|
|
|
|
+| 健康报告 / 上传记录 | 方案的来源报告不受影响 |
|
|
|
|
|
+
|
|
|
|
|
+如需「删除任务同时扣回积分」,属于后续增强,不在本期。
|
|
|
|
|
+
|
|
|
|
|
+## 4. 后端设计
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 新增接口
|
|
|
|
|
+
|
|
|
|
|
+统一 `@PostMapping`(项目规范禁止 PUT/DELETE Mapping),JWT 认证,走 Service 层:
|
|
|
|
|
+
|
|
|
|
|
+| 接口 | 角色/权限 | 说明 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| `POST /api/health/plan/delete` | 登录用户(`JwtInterceptor` 拦截) | 删除本家庭方案。`familyId` 从请求属性取,做 IDOR 归属校验 |
|
|
|
|
|
+| `POST /api/admin/health-plan/delete` | admin / teacher / butler / nutritionist / operator(与现有 admin 控制器一致) | 跨家庭删除 |
|
|
|
|
|
+
|
|
|
|
|
+请求参数:`{ "planId": 123 }`。
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 Service 方法
|
|
|
|
|
+
|
|
|
|
|
+`HealthPlanService` 新增:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+/**
|
|
|
|
|
+ * 删除健康方案及其级联任务。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param familyId 归属校验用家庭ID;传 null 跳过(管理端)
|
|
|
|
|
+ * @return {planId, taskCount};方案不存在抛异常,归属不匹配返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+Map<String, Object> deletePlanCascade(Long planId, Long familyId);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`HealthPlanServiceImpl` 实现,`@Transactional`(任务删一半失败时整体回滚,不留下无方案孤儿任务):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+1. 查方案,不存在 → 抛 RuntimeException("方案不存在")
|
|
|
|
|
+2. familyId 非空 且 方案.familyId 不匹配 → 返回 null
|
|
|
|
|
+3. 查直接任务IDs:source_type='health_plan' AND source_id=planId
|
|
|
|
|
+4. taskIds 非空 → 删除每日实例:taskMapper.delete(template_id IN taskIds) → 计入删除数
|
|
|
|
|
+5. 删除直接任务:taskMapper.delete(source_type='health_plan' AND source_id=planId) → 计入删除数
|
|
|
|
|
+6. healthPlanMapper.deleteById(planId)
|
|
|
|
|
+7. 返回 {planId: planId, taskCount: 直接+实例总数}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+实现要点:
|
|
|
|
|
+
|
|
|
|
|
+- `TaskMapper` 继承 `BaseMapper`,`delete(Wrapper)` 即可,无新增 XML;
|
|
|
|
|
+- 删除顺序必须「先实例后模板」,模板是实例的 `template_id` 引用对象,先删模板不影响实例删除逻辑,但按从外到内顺序执行语义更清晰;
|
|
|
|
|
+- 空 `taskIds` 时跳过实例删除(未生成任务的方案,如 draft);
|
|
|
|
|
+- 删除总数用于前端 toast 与实际删除数反馈。
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 Controller 错误处理
|
|
|
|
|
+
|
|
|
|
|
+| 场景 | 返回 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| planId 为空 | `Result.error("planId不能为空")` |
|
|
|
|
|
+| 方案不存在 | `Result.error("方案不存在")`(Service 抛业务异常) |
|
|
|
|
|
+| 归属不匹配(IDOR) | `Result.error("无权删除该方案")` |
|
|
|
|
|
+| 删除成功 | `Result.success({planId, taskCount})` |
|
|
|
|
|
+
|
|
|
|
|
+### 4.4 数据库变更
|
|
|
|
|
+
|
|
|
|
|
+无 schema 变更,纯删除逻辑,**不需要 DatabaseInitializer 迁移**。
|
|
|
|
|
+
|
|
|
|
|
+## 5. 前端设计
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 小程序(cfc-frontend)
|
|
|
|
|
+
|
|
|
|
|
+**`utils/api.js`** 新增(沿用现有封装风格):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+export const deleteHealthPlan = (data) => request('/api/health/plan/delete', 'POST', data)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**`pages/health/plan-list.vue`**:
|
|
|
|
|
+
|
|
|
|
|
+- 卡片右上角(状态标签左侧)增加「删除」文字按钮,`@click.stop` 阻止冒泡进入详情;
|
|
|
|
|
+- `onDeletePlan(plan)`:
|
|
|
|
|
+ 1. `uni.showModal`:title「删除方案」,content「删除后不可恢复:该方案及其已生成的所有任务(含每日生成的实例)将一并删除。确认删除?」;
|
|
|
|
|
+ 2. 用户确认 → `deleteHealthPlan({ planId: plan.id })`;
|
|
|
|
|
+ 3. 成功 → `uni.showToast('已删除方案及 ' + res.data.taskCount + ' 条任务')` → `loadPlans()` 刷新;
|
|
|
|
|
+ 4. 失败 → toast 后端 message;
|
|
|
|
|
+- 遵守 `cfc-frontend/AGENTS.md`:不用 `?.`、`:key` 保持 `plan.id`、删除为用户主动触发(不触发
|
|
|
|
|
+ `audit-duplicate-api-calls` 页面重复调用告警)。
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 Web 管理端(cfc-web)
|
|
|
|
|
+
|
|
|
|
|
+**`src/api/healthPlan.js`** 新增:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+export function adminDeleteHealthPlan(data) {
|
|
|
|
|
+ return request({ url: '/api/admin/health-plan/delete', method: 'post', data: data })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**`src/views/admin/HealthPlanManage.vue`**:操作列增加「删除」按钮 → `this.$confirm`
|
|
|
|
|
+(文案与小程序一致)→ 调接口 → 成功 `message` 提示 + `loadPlans()` 刷新,失败提示后端 message。
|
|
|
|
|
+复用页面现有 `loadPlans` 与操作列模式,不改路由。
|
|
|
|
|
+
|
|
|
|
|
+## 6. 错误处理汇总
|
|
|
|
|
+
|
|
|
|
|
+| 层 | 场景 | 处理 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| Service | 方案不存在 | 抛业务异常 → Controller 转 Result.error |
|
|
|
|
|
+| Service | familyId 不匹配 | 返回 null → Controller 转「无权删除该方案」 |
|
|
|
|
|
+| Service | 事务中断 | `@Transactional` 整体回滚,任务与方案均不删 |
|
|
|
|
|
+| Controller | 参数缺失 | 前置校验返回 error |
|
|
|
|
|
+| 前端 | 接口失败 | toast 后端 message,列表不刷新 |
|
|
|
|
|
+
|
|
|
|
|
+## 7. 文档同步
|
|
|
|
|
+
|
|
|
|
|
+- `docs/superpowers/api/API_REFERENCE.md`:在健康方案章节记录两个新接口(路径/参数/返回/错误码)。
|
|
|
|
|
+
|
|
|
|
|
+## 8. 验证方案
|
|
|
|
|
+
|
|
|
|
|
+| 项 | 命令/方式 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| 后端编译 | `cd cfc-backend && mvn clean compile` |
|
|
|
|
|
+| 小程序 | 提取 script 块 `node --check` 语法校验;**不执行打包**(HBuilderX 负责) |
|
|
|
|
|
+| 前端重复调用审计 | `node scripts/audit-duplicate-api-calls.js` |
|
|
|
|
|
+| 数据库 | 无迁移,无需验证 |
|
|
|
|
|
+
|
|
|
|
|
+## 9. 改动清单
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 改动 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java` | +`deletePlanCascade` 声明 |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` | +实现(`@Transactional`) |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java` | +`POST /api/health/plan/delete` |
|
|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java` | +`POST /api/admin/health-plan/delete` |
|
|
|
|
|
+| `cfc-frontend/utils/api.js` | +`deleteHealthPlan` |
|
|
|
|
|
+| `cfc-frontend/pages/health/plan-list.vue` | 删除按钮 + 确认弹窗 + 刷新 |
|
|
|
|
|
+| `cfc-web/src/api/healthPlan.js` | +`adminDeleteHealthPlan` |
|
|
|
|
|
+| `cfc-web/src/views/admin/HealthPlanManage.vue` | 删除按钮 + `$confirm` + 刷新 |
|
|
|
|
|
+| `docs/superpowers/api/API_REFERENCE.md` | 记录新接口 |
|