Ver Fonte

docs(sdd): 新增规划师审核工作台实现计划并同步 PROJECT-OVERVIEW

iwt há 2 dias atrás
pai
commit
732bd1eff4

+ 2 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -383,6 +383,7 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
+| `2026-10-04-plan-review-workbench.md` | 🟡 待执行(15 任务:任务 0 修 test-compile 基线 → 任务 1-7 后端授权/守卫/审核端点 → 任务 9-13 前端 3 页面 + 分包注册 + families.vue 入口 → 任务 14 文档同步与禁区检查) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库) |
 | `2026-09-10-report-quick-analyze.md` | 🟡 待执行(5 任务:Redis 依赖/配置 + AIChatController 抽 buildChatInputs 新增 report/analyze 端点 + api.js 封装 + report-detail 浮层 + 验证) | 报告快速分析实现计划 |
 | `2026-09-10-key-person-expansion-octopus.md` | 🟡 待执行(12 任务:迁移305-307 三表 + 3实体/Mapper/Service/Controller + 新 OctopusDiagram 三层画布 + 成效/关键人录入页 + action-detail 集成 + API 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |
 | `2026-08-18-homepage-health-funnel.md` | ✅ 已完成(登录态四段式重排 + 自测大卡 + 方法与计算属性 + 样式;未登录态零改动) | 首页健康漏斗实现计划 |
@@ -476,7 +477,7 @@
 | `2026-09-12-doa-family-goal-design.md` | 🟢 设计完成 | DOA 家庭打卡个人目标-周计划-周复盘设计(并列共同挑战新模块,5 领域/自选周期/家人可见/代管不能登录成员;设计稿:2026-09-12-doa-family-goal-design.md) |
 | `2026-09-12-vendor-order-review-design.md` | ✅ 已实施 | 供应商订单审核设计(pending 订单可修改价格/配送方式/添加临时优惠券/赠品,自动重算金额) |
 | `2026-09-19-task-orchestration-design.md` | 🟡 设计已确认(待写实现计划) | 任务编排系统设计(含环 DAG:多前置 AND/OR + 后置 timeout/failed 兜底 + 计数/条件循环终止 + Quartz 轮询 + jsPlumb 可视化画布;节点执行创建 tasks 实例复用积分体系;4 张新表) |
-| `2026-10-04-plan-review-workbench-design.md` | 🟡 设计已确认(待写实现计划) | 规划师审核工作台设计(A1:新增 6 个 `/api/guide/families/*` 端点 + 3 个前端页面;授权源统一为 `guide_families`(`status='binding'`);修复 3 处阻塞缺陷——`GuideFamilyTaskController` 7 个带 `{familyId}` 端点跨家庭越权、`confirmBind` 重绑不复活 status、`rejectPlan` 缺状态守卫;并使 approve 任务生成失败不再静默。轨 A 后续:A2 提醒/消息中心、A3 测评链路;轨 B:B1 合伙人分佣、B2 免费/收费服务合同) |
+| `2026-10-04-plan-review-workbench-design.md` | 🟢 设计已确认(实现计划已生成,见 `plans/2026-10-04-plan-review-workbench.md`,待实施) | 规划师审核工作台设计(A1:新增 6 个 `/api/guide/families/*` 端点 + 3 个前端页面;授权源统一为 `guide_families`(`status='binding'`);修复 3 处阻塞缺陷——`GuideFamilyTaskController` 7 个带 `{familyId}` 端点跨家庭越权、`confirmBind` 重绑不复活 status、`rejectPlan` 缺状态守卫;并使 approve 任务生成失败不再静默。轨 A 后续:A2 提醒/消息中心、A3 测评链路;轨 B:B1 合伙人分佣、B2 免费/收费服务合同) |
 
 ---
 

+ 2666 - 0
docs/superpowers/plans/2026-10-04-plan-review-workbench.md

@@ -0,0 +1,2666 @@
+# 规划师审核工作台(A1)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法跟踪进度。
+
+**目标:** 为成长规划师提供家庭服务审核工作台(方案审核 + 任务审核),并修复三类后端缺陷:跨家庭越权、绑定重绑失效、驳回缺状态守卫。
+
+**架构:** 授权源统一为 `guide_families` 表且 `status='binding'`,经 `GuideFamilyService.isBound(guideId, familyId)` 判定。新增 `GuideFamilyAccessGuard` 组件收敛授权规则,供既有 7 个越权端点与 6 个新端点共用。方案审核端点新增在 `/api/guide/families/{familyId}/plans/*`,原 `/api/health/plan/pending-review/*` 保留不动。
+
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + JUnit 5 + Mockito;前端 uni-app Vue 2 Options API(微信小程序)
+
+**设计规格:** `docs/superpowers/specs/2026-10-04-plan-review-workbench-design.md`
+
+---
+
+## 验证环境(实测,勿再假设)
+
+Maven **可用**,不在默认 PATH:
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+```
+
+| 命令 | 实测结果 |
+|---|---|
+| `mvn clean compile -DskipTests` | **exit 0 通过** |
+| `mvn test-compile` | **exit 1 失败**,恰好 2 个既有测试文件(见任务 0) |
+
+**无 DDL 变更:** 本计划不新增表/列,不改 `DatabaseInitializer.java`,不改 `schema.sql`。
+
+### 验证策略(重要)
+
+`src/test/resources/` 为空,无测试专用配置。因此**所有 `@SpringBootTest` 用例连接真实数据库**,`mvn test` 全量跑不可能稳定 `BUILD SUCCESS`。
+
+本计划的验证口径:
+
+| 层级 | 命令 | 说明 |
+|---|---|---|
+| 编译 | `mvn clean compile -DskipTests` | 主代码必须 0 错误 |
+| 测试编译 | `mvn test-compile` | 必须 exit 0 |
+| 新增单测 | `mvn test -Dtest=<新增用例>` | 纯 Mockito,不碰 DB,可稳定通过 |
+| 前端 | `vue-check.js`(见下) | 真实 SFC 解析 + 模板编译 + script 语法检查 |
+
+**禁止**把 `mvn test` 全量 `BUILD SUCCESS` 当作验收标准。
+
+### 前端验证脚本
+
+`node --check` **无法解析 `.vue`**(`ERR_UNKNOWN_FILE_EXTENSION`),不可用作校验手段。本计划改用 `vue-template-compiler`(已在 `cfc-frontend/node_modules`)真实解析 SFC。
+
+首次使用时创建:
+
+```bash
+mkdir -p /tmp/opencode/a1check
+cat > /tmp/opencode/a1check/vue-check.js <<'EOF'
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const compiler = require('/sc-data/cfc/cfc-frontend/node_modules/vue-template-compiler');
+
+let bad = 0;
+for (const f of process.argv.slice(2)) {
+  const sfc = compiler.parseComponent(fs.readFileSync(f, 'utf8'));
+  if (!sfc.template) { console.log('NO_TEMPLATE ' + f); bad++; continue; }
+  if (!sfc.script)   { console.log('NO_SCRIPT   ' + f); bad++; continue; }
+  const tpl = compiler.compile(sfc.template.content);
+  if (tpl.errors && tpl.errors.length) {
+    console.log('TEMPLATE_ERR ' + f + ' :: ' + tpl.errors.join(' | ')); bad++; continue;
+  }
+  const tmp = path.join(os.tmpdir(), 'a1check-' + path.basename(f) + '-' + process.pid + '.mjs');
+  fs.writeFileSync(tmp, sfc.script.content);
+  try {
+    execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' });
+    console.log('OK ' + f);
+  } catch (e) {
+    console.log('SCRIPT_ERR ' + f + ' :: ' + String(e.stderr || e.message).split('\n').slice(0, 3).join(' '));
+    bad++;
+  } finally {
+    fs.unlinkSync(tmp);
+  }
+}
+process.exit(bad ? 1 : 0);
+EOF
+```
+
+用法:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue
+```
+
+已实测:对 `pages/teacher/families.vue`、`pages/teacher/index.vue` 输出 `OK`。
+
+---
+
+## 项目约定(每个任务都适用)
+
+- **禁止** `@GetMapping` / `@PutMapping` / `@DeleteMapping`,统一 `@PostMapping`
+- DI 用 `@Resource`,字段名必须等于类型默认 Bean Name(如 `guideFamilyService`)
+- 响应统一 `Result<T>`;`Result.success()` code=200,`Result.error(msg)` code=500,`Result.error(code, msg)` 自定义
+- Controller 内手动检查 `@RequestAttribute("role")`
+- 请求参数用 `@RequestBody Map<String,Object> params` + `ParamUtils.getLong(...)`(`com.etotem.cfc.util.ParamUtils`)
+- **禁止**在 Controller 直接操作 Mapper
+- Lombok 可用(`@Data`、`@Slf4j` 已在项目中广泛使用)
+- 小程序:**禁**可选链 `?.`、**禁** CSS Grid(用 flexbox)、`:key` 用方法调用、时间用 `substring(0,19)` 且 `T` 换空格
+- 实体字段名务必核对,**不要臆造**(已核实:`Family.name` 不是 `familyName`;`HealthPlan` **无** `title` 字段;`Task` **无** `dueDate`,是 `deadline`)
+
+---
+
+## 关键事实(已核实,勿再假设)
+
+| 事实 | 值 |
+|---|---|
+| `GuideFamilyService.isBound(guideId, familyId)` | 已存在,`GuideFamilyService.java:152` |
+| `GuideFamilyService` 注入字段名 | `guideFamilyMapper` |
+| `generateDailyTasksFromPlan(HealthPlan)` | 返回 **`void`**,内部有 2 处提前 `return`,被 3 处调用(122/210/594)→ **不改返回类型** |
+| `approveAndPublish` | `HealthPlanServiceImpl.java:581`,**已有**状态守卫「方案状态不允许审核」 |
+| `rejectPlan` | `HealthPlanServiceImpl.java:602`,**无**状态守卫(本次修复目标) |
+| `updatePlanContent` | `HealthPlanServiceImpl.java:567`,已有守卫「方案状态不允许编辑」 |
+| `listPendingReviewPlans(familyId, teacherId)` | 已有 `teacher_id = ? OR teacher_id IS NULL` 过滤 + `created_at DESC` |
+| `HealthPlanServiceImpl` 注入字段 | `healthPlanMapper`、`taskMapper`、`familyMapper`、`userMapper`、`familyMemberMapper`、`selfCheckMapper` |
+| `GuideFamilyTaskController` 基础路径 | `/api/guide/families`,共 10 个端点 |
+| `TaskReviewDTO` 字段 | `result`、`aiSuggestion`、`overrideAi`、`approved`(Boolean)、`comment` → 审核用 `approved`,**不是** `action` |
+| `request` 签名 | `request(url, method = 'POST', data = {}, options = {})` |
+| 小程序 userId 来源 | `uni.getStorageSync('userId')` |
+| `pages/teacher` 路由形态 | `pages.json` 中是 **分包**(`"root": "pages/teacher"`),页面 `path` 写**裸名**,如 `"path": "index"` |
+| `pages/teacher/index.vue` | **已废弃**(2026-06-15 注释声明,保留仅参考,无路由访问)→ 入口改加在 `families.vue` |
+| `TestRoles.assertRoleDenied` | 断言 code=500 且 message 含 `Access denied` |
+
+---
+
+## 文件结构
+
+### 后端
+
+| 文件 | 动作 | 职责 |
+|---|---|---|
+| `service/GuideFamilyAccessGuard.java` | 创建 | 授权判定收敛:`checkBinding`(仅归属)+ `checkFamilyAccess`(角色+归属) |
+| `service/GuideFamilyService.java` | 修改 | `confirmBind` 重绑时复位 `status`/`boundAt` |
+| `service/HealthPlanService.java` | 修改 | 新增 `getPlanForFamily`;`approveAndPublish` 返回类型改 DTO |
+| `service/impl/HealthPlanServiceImpl.java` | 修改 | `rejectPlan` 补守卫;`approveAndPublish` 暴露 `taskGenerated`;实现 `getPlanForFamily` |
+| `controller/guide/GuideFamilyTaskController.java` | 修改 | 7 个带 `{familyId}` 端点接入 guard |
+| `controller/guide/GuidePlanReviewController.java` | 创建 | 方案审核 4 端点 |
+| `controller/guide/GuideFamilyQueryController.java` | 创建 | `my-families`、`{familyId}/members` 2 端点 |
+| `dto/PlanApproveResultDTO.java` | 创建 | approve 结果承载 `taskGenerated` |
+| `controller/HealthPlanController.java` | 修改 | 跟随 `approveAndPublish` 新返回类型 |
+
+### 前端
+
+| 文件 | 动作 | 职责 |
+|---|---|---|
+| `utils/api.js` | 修改 | 8 个新封装(6 新端点 + 2 任务审核) |
+| `pages/teacher/review-workbench.vue` | 创建 | 审核台首页:我的服务家庭列表 |
+| `pages/teacher/review-family.vue` | 创建 | 单家庭双 Tab(方案 / 任务) |
+| `pages/teacher/plan-detail.vue` | 创建 | 方案全文 + 编辑 + 通过/驳回 |
+| `pages.json` | 修改 | 在 `pages/teacher` 分包内注册 3 个新页面(裸 path) |
+| `pages/teacher/families.vue` | 修改 | 加入「审核工作台」入口(**不是** 已废弃的 index.vue) |
+
+### 测试
+
+| 文件 | 动作 |
+|---|---|
+| `src/test/.../integration/controller/ProductControllerTest.java` | 修改(任务 0 解锁 test-compile) |
+| `src/test/.../orchestration/OrchestrationEngineTest.java` | 修改(任务 0 解锁 test-compile) |
+| `src/test/.../integration/controller/GuideFamilyTaskControllerTest.java` | 修改(任务 3:签名适配 + MockBean guard) |
+| `src/test/.../unit/GuideFamilyAccessGuardTest.java` | 创建 |
+| `src/test/.../unit/GuideFamilyServiceBindTest.java` | 创建 |
+| `src/test/.../unit/HealthPlanReviewGuardTest.java` | 创建 |
+| `src/test/.../unit/GuidePlanReviewControllerAuthTest.java` | 创建 |
+| `src/test/.../unit/GuideFamilyQueryServiceTest.java` | 创建 |
+
+---
+
+### 任务 0:修复既有测试文件签名漂移(前置,解锁 test-compile)
+
+实测基线:`mvn test-compile` 恰好 2 个文件报错,共 10 处。
+
+**文件:**
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java`
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java`
+
+- [ ] **步骤 1:确认失败基线**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -oE "src/test/java/[^:]+\.java" | sort -u
+```
+
+预期:恰好 2 个文件 —— `ProductControllerTest.java`、`OrchestrationEngineTest.java`。
+
+- [ ] **步骤 2:修 ProductControllerTest 参数数量漂移**
+
+`ProductService.list` / `ProductController.list` 已增加第 3 参数 `String role`。实测报错 **8 处**(4 组调用),位于行 52/55、79/82、103/106、125/128。
+
+先确认实际签名与待改行:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "list(" src/main/java/com/etotem/cfc/service/ProductService.java src/main/java/com/etotem/cfc/controller/product/ProductController.java | head
+sed -n '50,56p;77,83p;101,107p;123,129p' src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
+```
+
+两处重复模式用 `replaceAll` 统一替换(各出现 4 次):
+
+oldStr(`when(...)` 形式):
+```java
+when(productService.list(any(ProductListQueryDTO.class), isNull()))
+```
+newStr:
+```java
+when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
+```
+
+oldStr(`controller.list(...)` 形式):
+```java
+controller.list(query, null)
+```
+newStr:
+```java
+controller.list(query, null, "admin")
+```
+
+- [ ] **步骤 3:修 OrchestrationEngineTest 的 void 返回值漂移**
+
+`engine.onTaskCompleted` / `onTaskFailed` 已返回 `void`,测试仍赋值给 `boolean`(行 148、170)。
+
+先读取两处上下文确认断言意图:
+
+```bash
+cd /sc-data/cfc/cfc-backend && sed -n '138,178p' src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
+```
+
+改法:去掉 `boolean result =` 与 `assertThat(result).isTrue()`,改为调用后校验副作用。**必须先确认该测试类中 `executionMapper` 字段确实存在**;若不存在,改用该测试实际持有的 mock 做等价断言,不要凭空 new 一个字段。
+
+```java
+        engine.onTaskCompleted(taskId);
+```
+```java
+        engine.onTaskFailed(taskId);
+```
+
+保留原有其他断言(如 `verify(...)`),仅移除对返回值的断言。
+
+- [ ] **步骤 4:验证 test-compile 通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
+git commit -m "fix(test): 修复 ProductControllerTest 与 OrchestrationEngineTest 的签名漂移"
+```
+
+---
+
+### 任务 1:GuideFamilyAccessGuard 授权组件
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java`
+
+**两个方法的分工(关键设计决策):**
+
+| 方法 | 用途 | 语义 |
+|---|---|---|
+| `checkBinding(guideId, familyId)` | 接入**既有** 6 个端点 | 仅校验家庭归属。既有端点自己已有角色校验(500 / `Access denied`),本方法**不重复**角色判断,从而不改变既有行为与既有测试断言 |
+| `checkFamilyAccess(role, userId, familyId)` | 供**新增** 6 个端点 | 角色 + 归属全量校验 |
+
+这样拆分的原因:`GuideFamilyTaskControllerTest` 断言非 teacher 角色返回 code=500 且 message 含 `Access denied`。若 guard 也返回自己的 403 文案,既有测试会全部失败。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyAccessGuardTest {
+
+    private GuideFamilyAccessGuard guard;
+    private GuideFamilyService svc;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        guard = new GuideFamilyAccessGuard();
+        svc = mock(GuideFamilyService.class);
+        Field f = GuideFamilyAccessGuard.class.getDeclaredField("guideFamilyService");
+        f.setAccessible(true);
+        f.set(guard, svc);
+    }
+
+    // ---- checkBinding ----
+
+    @Test
+    void checkBinding_已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkBinding(1L, 10L));
+    }
+
+    @Test
+    void checkBinding_未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        Result<Void> r = guard.checkBinding(1L, 10L);
+        assertNotNull(r);
+        assertEquals(403, r.getCode());
+        assertEquals("无权访问该家庭数据", r.getMessage());
+    }
+
+    @Test
+    void checkBinding_familyId为null返回403() {
+        assertEquals(403, guard.checkBinding(1L, null).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkBinding_guideId为null返回403() {
+        assertEquals(403, guard.checkBinding(null, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    // ---- checkFamilyAccess ----
+
+    @Test
+    void checkFamilyAccess_管理员跳过归属校验() {
+        assertNull(guard.checkFamilyAccess("admin", 1L, 10L));
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_规划师已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkFamilyAccess("teacher", 1L, 10L));
+    }
+
+    @Test
+    void checkFamilyAccess_规划师未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        assertEquals(403, guard.checkFamilyAccess("teacher", 1L, 10L).getCode());
+    }
+
+    @Test
+    void checkFamilyAccess_家长被拒() {
+        assertEquals(403, guard.checkFamilyAccess("parent", 1L, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_孩子被拒() {
+        assertEquals(403, guard.checkFamilyAccess("child", 1L, 10L).getCode());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "cannot find symbol|Tests run:|BUILD" | head -5
+```
+
+预期:`cannot find symbol: class GuideFamilyAccessGuard`。
+
+- [ ] **步骤 3:创建 guard 实现**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 规划师访问客户家庭的统一授权判定。
+ * 全项目唯一判定点,避免各控制器散写导致口径漂移。
+ */
+@Component
+public class GuideFamilyAccessGuard {
+
+    private static final String DENIED = "无权访问该家庭数据";
+
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 仅校验家庭归属,不做角色判断。
+     * 供已有角色校验的既有端点接入,避免改变其既有响应码与文案。
+     *
+     * @return null 表示放行;非 null 为 403 响应,调用方直接 return 即可
+     */
+    public Result<Void> checkBinding(Long guideId, Long familyId) {
+        if (guideId == null || familyId == null) {
+            return Result.error(403, DENIED);
+        }
+        if (!guideFamilyService.isBound(guideId, familyId)) {
+            return Result.error(403, DENIED);
+        }
+        return null;
+    }
+
+    /**
+     * 角色 + 归属全量校验,供新增端点使用。
+     * admin 跳过归属校验(运营兜底)。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    public Result<Void> checkFamilyAccess(String role, Long userId, Long familyId) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, DENIED);
+        }
+        if ("admin".equals(role)) {
+            return null;
+        }
+        return checkBinding(userId, familyId);
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 9, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java
+git commit -m "feat(guide): 新增 GuideFamilyAccessGuard 统一家庭访问授权判定"
+```
+
+---
+
+### 任务 2:修复 confirmBind 重绑不复活 status
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java`(`confirmBind` 内 `if (gf == null)` 分支)
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java`
+
+**缺陷:** `confirmBind` 只在「新建记录」时设 `status="binding"`。命中 `status='cancelled'` 的历史解绑行时走 update 分支,`status` 保持 `cancelled`,导致 `isBound()` 永远 false,重绑形同虚设。
+
+先读取当前实现确认行号:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "confirmBind" -A 40 src/main/java/com/etotem/cfc/service/GuideFamilyService.java | head -50
+```
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyServiceBindTest {
+
+    private GuideFamilyService service;
+    private GuideFamilyMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyService();
+        mapper = mock(GuideFamilyMapper.class);
+        Field f = GuideFamilyService.class.getDeclaredField("guideFamilyMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    @Test
+    void 重绑时应复活status为binding() {
+        GuideFamily cancelled = new GuideFamily();
+        cancelled.setId(7L);
+        cancelled.setGuideId(1L);
+        cancelled.setFamilyId(10L);
+        cancelled.setStatus("cancelled");
+
+        // 第 1 次 selectOne:查是否已绑定其他规划师 -> null
+        // 第 2 次 selectOne:查本规划师已有记录 -> cancelled 行
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null).thenReturn(cancelled);
+        when(mapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "paid", 199);
+
+        assertTrue(ok);
+        assertEquals("binding", cancelled.getStatus(), "重绑必须把 status 复位为 binding");
+        assertNotNull(cancelled.getBoundAt(), "重绑必须刷新 boundAt");
+        verify(mapper).updateById(cancelled);
+        verify(mapper, never()).insert(any(GuideFamily.class));
+    }
+
+    @Test
+    void 首次绑定应插入新行且status为binding() {
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        when(mapper.insert(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "free", 0);
+
+        assertTrue(ok);
+        verify(mapper).insert(argThat(g ->
+                "binding".equals(g.getStatus())
+                        && Long.valueOf(1L).equals(g.getGuideId())
+                        && Long.valueOf(10L).equals(g.getFamilyId())
+                        && g.getBoundAt() != null));
+    }
+
+    @Test
+    void 家庭已绑定他人应抛异常() {
+        GuideFamily other = new GuideFamily();
+        other.setGuideId(2L);
+        other.setFamilyId(10L);
+        other.setStatus("binding");
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(other);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.confirmBind(1L, 10L, "paid", 100));
+        assertTrue(ex.getMessage().contains("已绑定其他成长规划师"));
+        verify(mapper, never()).insert(any(GuideFamily.class));
+        verify(mapper, never()).updateById(any(GuideFamily.class));
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "重绑时应复活status|Tests run:|expected" | head -6
+```
+
+预期:`重绑时应复活status为binding` FAIL,实际 `"cancelled"` != `"binding"`。
+
+- [ ] **步骤 3:修复 confirmBind**
+
+把 `status` / `boundAt` 的赋值**移出** `if (gf == null)` 分支:
+
+原:
+```java
+        if (gf == null) {
+            gf = new GuideFamily();
+            gf.setGuideId(guideId);
+            gf.setFamilyId(familyId);
+            gf.setStatus("binding");
+            gf.setBoundAt(new Date());
+            gf.setCreatedAt(new Date());
+        }
+```
+
+改为:
+```java
+        if (gf == null) {
+            gf = new GuideFamily();
+            gf.setGuideId(guideId);
+            gf.setFamilyId(familyId);
+            gf.setCreatedAt(new Date());
+        }
+        // 无论新建还是命中已有行(含 status='cancelled' 的历史解绑记录),
+        // 都必须复位为 binding,否则 isBound() 永远返回 false,重绑形同虚设。
+        gf.setStatus("binding");
+        gf.setBoundAt(new Date());
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 3, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java
+git commit -m "fix(guide): confirmBind 重绑时复位 status 为 binding,修复解绑后无法重新绑定"
+```
+
+---
+
+### 任务 3:GuideFamilyTaskController 7 个越权端点接入 guard
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java`
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java`
+
+**现状核实结论(务必先读):**
+
+| 端点 | 行 | 是否已有 `@RequestAttribute("userId")` |
+|---|---|---|
+| `/{familyId}/overview` | 177 | **已有**(`Long guideId`) |
+| `/{familyId}/children/{memberId}/tasks` | 245 | 无 |
+| `/{familyId}/children/{memberId}/pending-review` | 270 | 无 |
+| `/{familyId}/tasks/{taskId}/review` | 290 | 无 |
+| `/{familyId}/tasks/batch-review` | 315 | 无 |
+| `/{familyId}/tasks/{taskId}` | 372 | 无 |
+| `/{familyId}/tasks/{taskId}/delete` | 423 | 无 |
+
+即:**7 个端点都要加 guard 调用,其中 6 个需补 `userId` 参数**(会破坏既有测试签名,步骤 5 一并修)。
+
+**不做**:`/unbind`(57) 已有归属校验;`/bound-families`(106) 与 `/dashboard-stats`(453) 只读自身数据、无 `familyId` 参数。
+
+- [ ] **步骤 1:注入 guard**
+
+在既有 `@Resource private GuideFamilyService guideFamilyService;` 之后新增:
+
+```java
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+```
+
+import 区新增:
+
+```java
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+```
+
+- [ ] **步骤 2:加统一调用辅助方法**
+
+在类的最后(最后一个 `}` 之前)新增:
+
+```java
+    /**
+     * 校验当前规划师是否为该家庭的绑定规划师。
+     * 既有端点自带角色校验(500 / Access denied),此处只补家庭归属校验。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    private Result<Void> requireBound(Long guideId, Long familyId) {
+        return guideFamilyAccessGuard.checkBinding(guideId, familyId);
+    }
+```
+
+- [ ] **步骤 3:为 6 个端点补 userId 参数**
+
+对下表 6 个端点,在签名中补 `@RequestAttribute("userId") Long guideId`(`/{familyId}/overview` 已有,跳过):
+
+| 行号 | 端点 |
+|---|---|
+| 245 | `/{familyId}/children/{memberId}/tasks` |
+| 270 | `/{familyId}/children/{memberId}/pending-review` |
+| 290 | `/{familyId}/tasks/{taskId}/review` |
+| 315 | `/{familyId}/tasks/batch-review` |
+| 372 | `/{familyId}/tasks/{taskId}` |
+| 423 | `/{familyId}/tasks/{taskId}/delete` |
+
+- [ ] **步骤 4:为 7 个端点接入 guard 调用**
+
+**插入位置(关键)**:紧跟在既有角色校验这一行**之后**、任何业务查询/写库之前:
+
+```java
+        if (!"teacher".equals(role)) { return Result.error("Access denied"); }   // ← 既有行,不动
+        Result<Void> denied = requireBound(guideId, familyId);                    // ← 插在这行下面
+        if (denied != null) return denied;
+```
+
+> ⚠️ **不要**把 guard 调用放在角色校验之前。否则未绑定家庭的非规划师请求会先被 guard 拦成 403,破坏既有 `Access denied` 语义(`GuideFamilyTaskControllerTest` 中 4 个非规划师用例会由 code=500 变 403 而失败)。
+
+各端点既有角色校验所在行(插入点即其下一行):`182`、`251`、`276`、`297`、`332`、`379`、`429`。
+
+确认 7 处都已接入:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -c "requireBound(guideId, familyId)" src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
+```
+
+预期:`7`。
+
+- [ ] **步骤 5:适配既有测试 GuideFamilyTaskControllerTest**
+
+该测试是 `@SpringBootTest` + `@MockBean`,直接按参数调用控制器方法。步骤 3 的签名变更会导致编译失败,且新增 guard 会真实查库导致断言失败。
+
+a) 新增 guard 的 MockBean(Mockito 默认返回 `null`,等价于放行,既有断言不受影响):
+
+```java
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+```
+
+并补 import:`com.etotem.cfc.service.GuideFamilyAccessGuard`。
+
+b) 补齐参数。先列出全部调用点:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "controller\.\(getFamilyTaskOverview\|getFamilyMemberTasks\|getPendingReviewTasks\|reviewTask\|batchReviewTasks\|updateTask\|deleteTask\)" src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java
+```
+
+按每个方法的新签名逐个补一个 `guideId` 实参(可用该用例已有的用户 ID,或统一传 `1L`)。**参数位置必须与方法签名严格一致**,改完立即编译验证。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean test-compile -q 2>&1 | grep -E "ERROR.*\.java" | head -20; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`,无 ERROR 行。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java
+git commit -m "fix(security): GuideFamilyTaskController 7 个端点接入绑定校验,修复跨家庭越权"
+```
+
+---
+
+### 任务 4:rejectPlan 补状态守卫 + getPlanForFamily(IDOR 反查)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java:602`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java`
+
+**说明:** IDOR 反查逻辑放 Service 而非 Controller,既满足「Controller 禁止直接操作 Mapper」,又便于单测。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.HealthPlan;
+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 java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HealthPlanReviewGuardTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        mapper = mock(HealthPlanMapper.class);
+        Field f = HealthPlanServiceImpl.class.getDeclaredField("healthPlanMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    private HealthPlan planWithStatus(String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(5L);
+        p.setFamilyId(10L);
+        p.setStatus(status);
+        return p;
+    }
+
+    // ---- rejectPlan 状态守卫 ----
+
+    @Test
+    void 已发布方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("published"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void 已驳回方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("rejected"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void pending_review方案可驳回() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        HealthPlan r = service.rejectPlan(5L, 1L, "内容需调整");
+
+        assertEquals("rejected", r.getStatus());
+        assertEquals(Long.valueOf(1L), r.getReviewedBy());
+        assertEquals("内容需调整", r.getReviewComment());
+        verify(mapper).updateById(p);
+    }
+
+    @Test
+    void draft方案可驳回() {
+        HealthPlan p = planWithStatus("draft");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        assertEquals("rejected", service.rejectPlan(5L, 1L, "c").getStatus());
+    }
+
+    @Test
+    void 方案不存在抛方案不存在() {
+        when(mapper.selectById(5L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案不存在", ex.getMessage());
+    }
+
+    // ---- getPlanForFamily IDOR 反查 ----
+
+    @Test
+    void getPlanForFamily_匹配返回方案() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        HealthPlan p = service.getPlanForFamily(99L, 10L);
+        assertNotNull(p);
+        assertEquals(Long.valueOf(99L), p.getId());
+    }
+
+    @Test
+    void getPlanForFamily_不属于该家庭返回null() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        assertNull(service.getPlanForFamily(99L, 20L));
+    }
+
+    @Test
+    void getPlanForFamily_方案不存在抛异常() {
+        when(mapper.selectById(99L)).thenReturn(null);
+        assertEquals("方案不存在",
+                assertThrows(RuntimeException.class, () -> service.getPlanForFamily(99L, 10L)).getMessage());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "已发布方案不可再次驳回|cannot find symbol|Tests run:" | head -5
+```
+
+预期:`cannot find symbol: method getPlanForFamily`,且已发布方案驳回用例 FAIL。
+
+- [ ] **步骤 3:补 rejectPlan 状态守卫**
+
+`HealthPlanServiceImpl.rejectPlan` 现有开头:
+
+```java
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+```
+
+在其后补守卫(与 `approveAndPublish` 口径一致):
+
+```java
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
+```
+
+- [ ] **步骤 4:新增 getPlanForFamily**
+
+接口 `HealthPlanService` 增加:
+
+```java
+    /**
+     * IDOR 防护:校验方案确实属于指定家庭。
+     *
+     * @return 匹配返回方案;familyId 不匹配返回 null;方案不存在抛异常
+     */
+    HealthPlan getPlanForFamily(Long planId, Long familyId);
+```
+
+实现 `HealthPlanServiceImpl`:
+
+```java
+    @Override
+    public HealthPlan getPlanForFamily(Long planId, Long familyId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (plan.getFamilyId() == null || !plan.getFamilyId().equals(familyId)) {
+            return null;
+        }
+        return plan;
+    }
+```
+
+- [ ] **步骤 5:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 8, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java
+git commit -m "fix(health): rejectPlan 补状态守卫并新增 getPlanForFamily IDOR 反查"
+```
+
+---
+
+### 任务 5:approveAndPublish 暴露 taskGenerated
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java:23`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java:581`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java:203`
+- 测试:追加到 `HealthPlanReviewGuardTest.java`
+
+**关键决策:不改 `generateDailyTasksFromPlan` 返回类型。** 该方法返回 `void`、内部有 2 处提前 `return`、被 3 处调用,改成 `int` 需改动大量既有逻辑,风险不成比例。改为**生成后查库统计**:
+
+- 幂等跳过场景:任务已存在 → `selectList` 能查到 → `taskCount > 0` → `taskGenerated=true`,语义正确。
+- 生成异常:catch → `taskGenerated=false`。
+- 方案本身无任务:`taskCount=0` → `taskGenerated=false`,message 用「未生成任何任务」,语义可接受。
+
+- [ ] **步骤 1:创建 DTO**
+
+`cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java`:
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 方案审核通过结果。
+ * taskGenerated=false 表示方案已发布但未生成任何任务,前端必须提示用户。
+ */
+@Data
+public class PlanApproveResultDTO {
+    private Long planId;
+    private String status;
+    private Boolean taskGenerated;
+    private Integer taskCount;
+    private String message;
+}
+```
+
+- [ ] **步骤 2:编写失败的测试**
+
+追加到 `HealthPlanReviewGuardTest.java`(同时在 `setUp` 中注入 `taskMapper` mock):
+
+```java
+    @Test
+    void 任务生成成功时taskGenerated为true() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Arrays.asList(new Task(), new Task()));
+
+        com.etotem.cfc.dto.PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus());
+        assertTrue(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(2), r.getTaskCount());
+    }
+
+    @Test
+    void 未生成任何任务时taskGenerated为false但方案仍发布() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        com.etotem.cfc.dto.PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus(), "任务生成失败不得回滚方案发布");
+        assertFalse(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(0), r.getTaskCount());
+        assertTrue(r.getMessage().contains("未生成任何任务"));
+    }
+```
+
+补 import:`com.etotem.cfc.entity.Task`、`java.util.Arrays`、`java.util.Collections`。
+
+- [ ] **步骤 3:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "cannot find symbol|incompatible types|Tests run:" | head -5
+```
+
+预期:`cannot find symbol: class PlanApproveResultDTO`。
+
+- [ ] **步骤 4:改造 approveAndPublish**
+
+`HealthPlanServiceImpl.java:581` 方法改为:
+
+```java
+    @Override
+    public PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
+        plan.setStatus("published");
+        plan.setReviewedBy(reviewedBy);
+        plan.setReviewedBy(reviewedBy);
+        plan.setReviewedAt(new Date());
+        plan.setReviewComment(comment);
+        plan.setUpdatedAt(new Date());
+        healthPlanMapper.updateById(plan);
+
+        boolean taskGenerated = true;
+        int taskCount = 0;
+        try {
+            generateDailyTasksFromPlan(plan);
+            List<Task> generated = taskMapper.selectList(new QueryWrapper<Task>()
+                    .eq("source_type", "health_plan")
+                    .eq("source_id", planId));
+            taskCount = generated == null ? 0 : generated.size();
+            taskGenerated = taskCount > 0;
+        } catch (Exception e) {
+            // 方案已发布,任务生成失败不能静默——必须让调用方知道
+            taskGenerated = false;
+            taskCount = 0;
+            log.warn("方案{}发布时生成任务失败: {}", planId, e.getMessage());
+        }
+
+        PlanApproveResultDTO dto = new PlanApproveResultDTO();
+        dto.setPlanId(planId);
+        dto.setStatus(plan.getStatus());
+        dto.setTaskGenerated(taskGenerated);
+        dto.setTaskCount(taskCount);
+        dto.setMessage(taskGenerated ? "方案已发布并生成任务" : "方案已发布,但未生成任何任务,请检查方案内容");
+        return dto;
+    }
+```
+
+注意:上面的 `plan.setReviewedBy(reviewedBy);` **只应出现一次**(原实现已有该行,改造时勿重复添加)。
+
+同时 import `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+- [ ] **步骤 5:同步接口签名与既有调用方**
+
+`HealthPlanService.java:23` 改为:
+
+```java
+    PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment);
+```
+
+并加 import `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+`HealthPlanController.java:203` 附近改为:
+
+```java
+    @Operation(summary = "规划师审核通过并发布(自动生成任务)")
+    @PostMapping("/pending-review/approve")
+    public Result<PlanApproveResultDTO> approvePlan(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        String comment = (String) params.get("comment");
+        PlanApproveResultDTO r = healthPlanService.approveAndPublish(planId, userId, comment);
+        return Result.success(r);
+    }
+```
+
+import 区新增 `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+**安全性说明:** 该端点无前端消费者(`utils/api.js:2540` 的 `approvePlan` 无任何页面引用),改返回类型不会破坏现有前端。
+
+- [ ] **步骤 6:编译并跑测试**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+```
+
+预期:`Tests run: 10, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java
+git commit -m "feat(health): approveAndPublish 返回 taskGenerated,任务生成失败不再静默"
+```
+
+---
+
+### 任务 6:GuidePlanReviewController 方案审核 4 端点
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java`
+
+**端点:**
+- `POST /api/guide/families/{familyId}/plans/pending`
+- `POST /api/guide/families/{familyId}/plans/{planId}/update`
+- `POST /api/guide/families/{familyId}/plans/{planId}/approve`
+- `POST /api/guide/families/{familyId}/plans/{planId}/reject`
+
+**IDOR 防护:** `{planId}` 类端点调 `healthPlanService.getPlanForFamily(planId, familyId)` 反查,返回 `null` 即 403。授权用**反查出的真实 familyId** 再校验一次。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+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.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuidePlanReviewControllerAuthTest {
+
+    private GuidePlanReviewController controller;
+    private HealthPlanService planService;
+    private GuideFamilyAccessGuard guard;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new GuidePlanReviewController();
+        planService = mock(HealthPlanService.class);
+        guard = mock(GuideFamilyAccessGuard.class);
+        set("healthPlanService", planService);
+        set("guideFamilyAccessGuard", guard);
+    }
+
+    private void set(String name, Object v) throws Exception {
+        Field f = GuidePlanReviewController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, v);
+    }
+
+    private HealthPlan plan(long id, long familyId) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus("pending_review");
+        return p;
+    }
+
+    @Test
+    void 未绑定家庭应返回403且不查方案() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L))
+                .thenReturn(Result.error(403, "无权访问该家庭数据"));
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).listPendingReviewPlans(any(), any());
+    }
+
+    @Test
+    void planId不属于路径familyId应返回403() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.approvePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).approveAndPublish(any(), any(), any());
+    }
+
+    @Test
+    void 已绑定且planId匹配应放行() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(plan(99L, 10L));
+        when(planService.listPendingReviewPlans(10L, 1L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    void 驳回端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.rejectPlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).rejectPlan(any(), any(), any());
+    }
+
+    @Test
+    void 编辑端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.updatePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).updatePlanContent(any(), any(), any());
+    }
+
+    @Test
+    void 管理员可按teacherId过滤() {
+        when(guard.checkFamilyAccess("admin", 1L, 10L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("teacherId", 77L);
+        when(planService.listPendingReviewPlans(10L, 77L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(params, "admin", 1L, 10L);
+
+        assertEquals(200, r.getCode());
+        verify(planService).listPendingReviewPlans(10L, 77L);
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+```
+
+预期:`cannot find symbol: class GuidePlanReviewController`。
+
+- [ ] **步骤 3:创建控制器**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java`:
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-客户方案审核", description = "规划师审核其绑定客户的健康方案")
+@RestController
+@RequestMapping("/api/guide/families/{familyId}/plans")
+public class GuidePlanReviewController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "待审核方案列表")
+    @PostMapping("/pending")
+    public Result<List<HealthPlan>> listPendingPlans(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return denied;
+
+        Object rawTeacherId = params == null ? null : params.get("teacherId");
+        Long teacherId = "admin".equals(role) ? ParamUtils.getLong(rawTeacherId) : userId;
+        return Result.success(healthPlanService.listPendingReviewPlans(familyId, teacherId));
+    }
+
+    @Operation(summary = "编辑方案内容")
+    @PostMapping("/{planId}/update")
+    public Result<HealthPlan> updatePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return denied;
+
+        String planContent = params == null ? null : (String) params.get("planContent");
+        String planJson = params == null ? null : (String) params.get("planJson");
+        return Result.success(healthPlanService.updatePlanContent(planId, planContent, planJson));
+    }
+
+    @Operation(summary = "审核通过并发布")
+    @PostMapping("/{planId}/approve")
+    public Result<PlanApproveResultDTO> approvePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return denied;
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.approveAndPublish(planId, userId, comment));
+    }
+
+    @Operation(summary = "驳回方案")
+    @PostMapping("/{planId}/reject")
+    public Result<HealthPlan> rejectPlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return denied;
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.rejectPlan(planId, userId, comment));
+    }
+
+    /**
+     * IDOR 防护:planId 必须真实属于路径 familyId,且当前角色对该 familyId 有绑定权限。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    private Result<Void> resolvePlan(String role, Long userId, Long familyId, Long planId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return denied;
+
+        HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId);
+        if (plan == null) {
+            return Result.error(403, "无权访问该方案");
+        }
+        // 用反查出的真实 familyId 再校验一次,防止路径与库内不一致绕过
+        Result<Void> recheck = guideFamilyAccessGuard.checkFamilyAccess(role, userId, plan.getFamilyId());
+        if (recheck != null) return recheck;
+
+        return null;
+    }
+}
+```
+
+- [ ] **步骤 4:编译并跑测试**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+```
+
+预期:`Tests run: 6, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java
+git commit -m "feat(guide): 新增客户家庭方案审核 4 端点,含 planId IDOR 反查防护"
+```
+
+---
+
+### 任务 7:my-families 与 members 端点
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java`
+
+**为什么不放 Controller 直接查 Mapper:** `cfc-backend/AGENTS.md` 明令禁止。
+
+**端点:**
+- `POST /api/guide/families/my-families` —— 按 `guide_families`(`status='binding'`)返回我的服务家庭
+- `POST /api/guide/families/{familyId}/members` —— 客户家庭成员列表
+
+- [ ] **步骤 1:创建 Service**
+
+`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class GuideFamilyQueryService {
+
+    @Resource
+    private GuideFamilyMapper guideFamilyMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /**
+     * 我的服务家庭:授权源与 isBound() 同为 guide_families(status='binding'),
+     * 保证列表与授权判定同源,避免「列表里有、点进去 403」。
+     */
+    public List<Map<String, Object>> listMyBoundFamilies(Long guideId) {
+        List<GuideFamily> bindings = guideFamilyMapper.selectList(
+                new QueryWrapper<GuideFamily>()
+                        .eq("guide_id", guideId)
+                        .eq("status", "binding")
+                        .orderByDesc("bound_at"));
+        if (bindings == null || bindings.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<Long> familyIds = bindings.stream()
+                .map(GuideFamily::getFamilyId)
+                .filter(java.util.Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<Long, String> nameByFamily = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<Family> families = familyMapper.selectBatchIds(familyIds);
+            if (families != null) {
+                for (Family f : families) {
+                    nameByFamily.put(f.getId(), f.getName());
+                }
+            }
+        }
+
+        Map<Long, Integer> memberCountByFamily = new LinkedHashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new QueryWrapper<FamilyMember>().in("family_id", familyIds));
+            if (members != null) {
+                for (FamilyMember m : members) {
+                    Long fid = m.getFamilyId();
+                    memberCountByFamily.merge(fid, 1, Integer::sum);
+                }
+            }
+        }
+
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (GuideFamily gf : bindings) {
+            Long fid = gf.getFamilyId();
+            Map<String, Object> row = new HashMap<>();
+            row.put("familyId", fid);
+            row.put("familyName", nameByFamily.get(fid));
+            row.put("serviceType", gf.getServiceType());
+            row.put("servicePrice", gf.getServicePrice());
+            row.put("boundAt", gf.getBoundAt());
+            row.put("memberCount", memberCountByFamily.getOrDefault(fid, 0));
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 客户家庭成员列表(显式 familyId,由调用方先做绑定校验) */
+    public List<FamilyMember> listFamilyMembers(Long familyId) {
+        return familyMemberMapper.selectList(
+                new QueryWrapper<FamilyMember>().eq("family_id", familyId));
+    }
+}
+```
+
+**注意:** 家庭名字段是 `Family.name`(不是 `familyName`)。已核实 `Family` 实体字段:`id`、`name`、`inviteCode`、`streetId`、`province`、`city`、`district`、`street`、`teacherId`、`butlerId`、`creatorId`、`createdAt`、`updatedAt`。
+
+- [ ] **步骤 2:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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 GuideFamilyQueryServiceTest {
+
+    private GuideFamilyQueryService service;
+    private GuideFamilyMapper gfMapper;
+    private FamilyMapper familyMapper;
+    private FamilyMemberMapper fmMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyQueryService();
+        gfMapper = mock(GuideFamilyMapper.class);
+        familyMapper = mock(FamilyMapper.class);
+        fmMapper = mock(FamilyMemberMapper.class);
+        set("guideFamilyMapper", gfMapper);
+        set("familyMapper", familyMapper);
+        set("familyMemberMapper", fmMapper);
+    }
+
+    private void set(String n, Object v) throws Exception {
+        Field f = GuideFamilyQueryService.class.getDeclaredField(n);
+        f.setAccessible(true);
+        f.set(service, v);
+    }
+
+    private GuideFamily binding(long familyId) {
+        GuideFamily gf = new GuideFamily();
+        gf.setFamilyId(familyId);
+        gf.setServiceType("paid");
+        gf.setServicePrice(199);
+        return gf;
+    }
+
+    private FamilyMember member(long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(1L);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    @Test
+    void 无绑定时返回空列表() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertTrue(service.listMyBoundFamilies(1L).isEmpty());
+        verify(familyMapper, never()).selectBatchIds(any());
+    }
+
+    @Test
+    void 返回绑定家庭的名称与成员数() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.singletonList(binding(10L)));
+
+        Family family = new Family();
+        family.setId(10L);
+        family.setName("张家");
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(family));
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(10L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(1, out.size());
+        assertEquals(10L, out.get(0).get("familyId"));
+        assertEquals("张家", out.get(0).get("familyName"));
+        assertEquals(3, out.get(0).get("memberCount"));
+        assertEquals("paid", out.get(0).get("serviceType"));
+    }
+
+    @Test
+    void 多家庭成员数分别统计() {
+        when(gfMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(binding(10L), binding(20L)));
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.emptyList());
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(20L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(2, out.size());
+        assertEquals(2, out.get(0).get("memberCount"));
+        assertEquals(1, out.get(1).get("memberCount"));
+    }
+
+    @Test
+    void 成员列表按familyId查询() {
+        when(fmMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertNotNull(service.listFamilyMembers(10L));
+        verify(fmMapper).selectList(any(QueryWrapper.class));
+    }
+}
+```
+
+- [ ] **步骤 3:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+```
+
+预期:`cannot find symbol: class GuideFamilyQueryService`。
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -6
+```
+
+预期:`Tests run: 4, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:创建 Controller**
+
+`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java`:
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-服务家庭查询", description = "规划师查询自己服务的家庭与客户家庭成员")
+@RestController
+@RequestMapping("/api/guide/families")
+public class GuideFamilyQueryController {
+
+    @Resource
+    private GuideFamilyQueryService guideFamilyQueryService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "我的服务家庭列表(授权源 guide_families)")
+    @PostMapping("/my-families")
+    public Result<List<Map<String, Object>>> myFamilies(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId) {
+        // 本端点语义为「我自己的家庭」,无 familyId 维度,故不用 checkFamilyAccess(其要求 familyId 非空)
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权访问该接口");
+        }
+        return Result.success(guideFamilyQueryService.listMyBoundFamilies(userId));
+    }
+
+    @Operation(summary = "客户家庭成员列表")
+    @PostMapping("/{familyId}/members")
+    public Result<List<FamilyMember>> familyMembers(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return denied;
+
+        return Result.success(guideFamilyQueryService.listFamilyMembers(familyId));
+    }
+}
+```
+
+注意:`my-families` **不能**用 `checkFamilyAccess(role, userId, null)` —— 该方法要求 `familyId` 非空,teacher 会因 `familyId == null` 被误拒。此端点语义是「我自己的家庭」,无家庭维度,故用上面的显式角色判断。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean compile -DskipTests -q 2>&1 | tail -10; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`。
+
+- [ ] **步骤 7:检查路由不重复**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@PostMapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:**无输出**(无重复字面量路径)。
+
+注意 `/{familyId}/members` 与 `GuideFamilyTaskController` 的 `/{familyId}/overview` 等不冲突(静态段不同)。
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java
+git commit -m "feat(guide): 新增我的服务家庭与客户家庭成员查询端点"
+```
+
+---
+
+### 任务 8:后端验证
+
+- [ ] **步骤 1:主代码编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean compile -DskipTests -q 2>&1 | tail -10; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`。
+
+- [ ] **步骤 2:测试编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 3:跑全部新增单测**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:|BUILD" | tail -8
+```
+
+预期:全部 `Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 4:确认无重复路由**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:无输出。
+
+- [ ] **步骤 5:Commit(若有修复)**
+
+```bash
+git add -A cfc-backend
+git commit -m "test: A1 后端编译与新增单测验证通过"
+```
+
+---
+
+### 任务 9:前端 API 封装
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+
+- [ ] **步骤 1:在文件末尾新增 8 个封装**
+
+已核实 `request` 签名为 `(url, method = 'POST', data = {}, options = {})`,3 参调用安全。
+
+```js
+// ===== 规划师审核工作台(A1) =====
+
+/** 我的服务家庭列表(授权源 guide_families) */
+export const getGuideMyFamilies = () =>
+  request('/api/guide/families/my-families', 'POST')
+
+/** 客户家庭成员列表 */
+export const getGuideFamilyMembers = (familyId) =>
+  request(`/api/guide/families/${familyId}/members`, 'POST')
+
+/** 某家庭待审核方案列表(admin 可传 teacherId 过滤) */
+export const getGuidePendingPlans = (familyId, teacherId) => {
+  const data = {}
+  if (teacherId) data.teacherId = teacherId
+  return request(`/api/guide/families/${familyId}/plans/pending`, 'POST', data)
+}
+
+/** 编辑方案内容 */
+export const updateGuidePlanContent = (familyId, planId, planContent, planJson) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/update`, 'POST', {
+    planContent,
+    planJson
+  })
+
+/** 审核通过并发布,返回 { planId, status, taskGenerated, taskCount, message } */
+export const approveGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/approve`, 'POST', { comment })
+
+/** 驳回方案 */
+export const rejectGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/reject`, 'POST', { comment })
+
+/** 某家庭某成员待审核任务 */
+export const getGuideChildPendingTasks = (familyId, memberId) =>
+  request(`/api/guide/families/${familyId}/children/${memberId}/pending-review`, 'POST')
+
+/** 审核家庭任务:approved = true 通过 / false 驳回(字段名对齐 TaskReviewDTO) */
+export const reviewGuideTask = (familyId, taskId, approved, comment) =>
+  request(`/api/guide/families/${familyId}/tasks/${taskId}/review`, 'POST', {
+    approved,
+    comment
+  })
+```
+
+**关键:** `TaskReviewDTO` 已核实字段为 `result` / `aiSuggestion` / `overrideAi` / `approved`(Boolean) / `comment`。审核结果必须用 `approved`,**不能**用 `action`。
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc && node --check cfc-frontend/utils/api.js && echo "OK"
+```
+
+预期:`OK`(`api.js` 是纯 JS,`node --check` 有效)。
+
+- [ ] **步骤 3:确认无重复导出**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && grep -c "export const getGuideMyFamilies\|export const reviewGuideTask" utils/api.js
+```
+
+预期:`2`(各 1 次)。若 >2 说明重复定义,必须清理。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat(frontend): 新增审核工作台 8 个 API 封装"
+```
+
+---
+
+### 任务 10:review-workbench.vue 审核台首页
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/review-workbench.vue`
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="header">
+      <text class="title">审核工作台</text>
+      <text class="sub">我服务的家庭</text>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+
+    <view v-else-if="families.length === 0" class="empty">
+      尚未绑定任何家庭
+    </view>
+
+    <view v-else>
+      <view
+        v-for="f in families"
+        :key="getFamilyKey(f)"
+        class="card"
+        @click="goFamily(f)"
+      >
+        <view class="row">
+          <text class="name">{{ f.familyName || ('家庭 #' + f.familyId) }}</text>
+          <text class="badge">{{ f.serviceType === 'paid' ? '收费' : '免费' }}</text>
+        </view>
+        <view class="row">
+          <text class="meta">成员 {{ f.memberCount || 0 }} 人</text>
+          <text class="meta">绑定于 {{ formatTime(f.boundAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getGuideMyFamilies } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      families: []
+    }
+  },
+  onLoad() {
+    this.fetchFamilies()
+  },
+  methods: {
+    async fetchFamilies() {
+      this.loading = true
+      try {
+        const res = await getGuideMyFamilies()
+        if (res && res.code === 200) {
+          this.families = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    getFamilyKey(f) {
+      return 'gf-' + f.familyId
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目规范:yyyy-MM-dd HH:mm:ss,取前19位并把 T 换成空格
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goFamily(f) {
+      uni.navigateTo({
+        url: '/pages/teacher/review-family?familyId=' + f.familyId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.header { padding: 24rpx 8rpx; }
+.title { font-size: 36rpx; font-weight: 600; color: #1F2D3D; display: block; }
+.sub { font-size: 26rpx; color: #8A94A6; margin-top: 8rpx; display: block; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>
+```
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue
+```
+
+预期:`OK pages/teacher/review-workbench.vue`。
+
+若脚本不存在,见本计划「前端验证脚本」一节重新创建。
+
+- [ ] **步骤 3:确认无禁用写法**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid|display:\s*inline-grid" cfc-frontend/pages/teacher/review-workbench.vue || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/review-workbench.vue
+git commit -m "feat(frontend): 新增规划师审核工作台首页"
+```
+
+---
+
+### 任务 11:review-family.vue 单家庭双 Tab
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/review-family.vue`
+
+**依赖:** 任务 9 的 `getGuideFamilyMembers`、`getGuidePendingPlans`、`getGuideChildPendingTasks`、`reviewGuideTask`;任务 3 的任务端点绑定校验。
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="tabs">
+      <view class="tab" :class="{ active: tab === 'plan' }" @click="switchTab('plan')">方案</view>
+      <view class="tab" :class="{ active: tab === 'task' }" @click="switchTab('task')">任务</view>
+    </view>
+
+    <!-- Tab 1 方案 -->
+    <view v-if="tab === 'plan'">
+      <view v-if="plans.length === 0" class="empty">暂无待审核方案</view>
+      <view
+        v-for="p in plans"
+        :key="getPlanKey(p)"
+        class="card"
+        @click="goPlan(p)"
+      >
+        <view class="row">
+          <text class="name">{{ planTitle(p) }}</text>
+          <text class="badge" v-if="isMine(p)">已分配给我</text>
+          <text class="badge grey" v-else>我的客户</text>
+        </view>
+        <text class="meta">创建于 {{ formatTime(p.createdAt) }}</text>
+      </view>
+    </view>
+
+    <!-- Tab 2 任务 -->
+    <view v-else>
+      <view class="member-bar">
+        <picker
+          mode="selector"
+          :range="memberNames"
+          :value="memberIndex"
+          @change="onMemberChange"
+        >
+          <view class="picker">成员:{{ memberNames[memberIndex] || '请选择' }}</view>
+        </picker>
+      </view>
+
+      <view v-if="!selectedMemberId" class="empty">请先选择成员</view>
+      <view v-else-if="tasks.length === 0" class="empty">暂无待审核任务</view>
+
+      <view v-else>
+        <view v-for="t in tasks" :key="getTaskKey(t)" class="card">
+          <text class="name">{{ t.title }}</text>
+          <text class="meta">截止 {{ formatTime(t.deadline) }}</text>
+          <view class="actions">
+            <view class="btn ok" @click="reviewTask(t, true)">通过</view>
+            <view class="btn no" @click="reviewTask(t, false)">驳回</view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  getGuideFamilyMembers,
+  getGuidePendingPlans,
+  getGuideChildPendingTasks,
+  reviewGuideTask
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      myUserId: null,
+      tab: 'plan',
+      plans: [],
+      members: [],
+      memberIndex: 0,
+      selectedMemberId: null,
+      tasks: []
+    }
+  },
+  computed: {
+    memberNames() {
+      return this.members.map(m => m.nickname || ('成员' + m.id))
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    // 项目既有取值方式:uni.getStorageSync('userId')
+    this.myUserId = uni.getStorageSync('userId') || null
+    this.fetchMembers()
+    this.fetchPlans()
+  },
+  methods: {
+    async fetchMembers() {
+      try {
+        const res = await getGuideFamilyMembers(this.familyId)
+        if (res && res.code === 200) {
+          this.members = res.data || []
+        }
+      } catch (e) {
+        uni.showToast({ title: '成员加载失败', icon: 'none' })
+      }
+    },
+    async fetchPlans() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          this.plans = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '方案加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '方案加载失败', icon: 'none' })
+      }
+    },
+    switchTab(t) {
+      this.tab = t
+      if (t === 'task' && this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    onMemberChange(e) {
+      this.memberIndex = Number(e.detail.value)
+      const m = this.members[this.memberIndex]
+      this.selectedMemberId = m ? m.id : null
+      if (this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    async fetchTasks() {
+      try {
+        const res = await getGuideChildPendingTasks(this.familyId, this.selectedMemberId)
+        if (res && res.code === 200) {
+          this.tasks = res.data || []
+        } else {
+          this.tasks = []
+          uni.showToast({ title: (res && res.message) || '无权访问或暂无数据', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '任务加载失败', icon: 'none' })
+      }
+    },
+    async reviewTask(t, approved) {
+      try {
+        const res = await reviewGuideTask(this.familyId, t.id, approved, '')
+        if (res && res.code === 200) {
+          uni.showToast({ title: approved ? '已通过' : '已驳回', icon: 'none' })
+          this.fetchTasks()
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    planTitle(p) {
+      // HealthPlan 无 title 字段,标题优先取 goal,其次 memberName
+      return p.goal || p.memberName || ('健康方案 #' + p.id)
+    },
+    isMine(p) {
+      return !!p.teacherId && String(p.teacherId) === String(this.myUserId)
+    },
+    getPlanKey(p) { return 'gp-' + p.id },
+    getTaskKey(t) { return 'gt-' + t.id },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goPlan(p) {
+      uni.navigateTo({
+        url: '/pages/teacher/plan-detail?familyId=' + this.familyId + '&planId=' + p.id
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.tabs { display: flex; flex-direction: row; background: #fff; border-radius: 12rpx; margin-bottom: 20rpx; }
+.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #8A94A6; }
+.tab.active { color: #4A9BD7; font-weight: 600; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.badge.grey { color: #8A94A6; background: #F2F4F7; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.member-bar { background: #fff; border-radius: 12rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; }
+.picker { font-size: 28rpx; color: #1F2D3D; }
+.actions { display: flex; flex-direction: row; margin-top: 16rpx; }
+.btn { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 12rpx; font-size: 26rpx; margin-right: 16rpx; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>
+```
+
+**关键修正(相对常见错误写法):**
+- `Task` 实体**无** `dueDate`,截止时间是 `deadline`。
+- `HealthPlan` 实体**无** `title`,标题用 `goal` / `memberName`。
+- userId 用 `uni.getStorageSync('userId')`(项目既有模式),不用 `getApp().globalData`。
+- 审核传 `approved` 布尔,不用 `action` 字符串。
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-family.vue
+```
+
+预期:`OK`。
+
+- [ ] **步骤 3:违规写法扫描**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/review-family.vue cfc-frontend/utils/api.js || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/review-family.vue
+git commit -m "feat(frontend): 新增单家庭方案/任务双 Tab 审核页"
+```
+
+---
+
+### 任务 12:plan-detail.vue 方案详情
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/plan-detail.vue`
+
+**数据来源说明:** 无单方案查询端点,故按 `familyId` 拉待审列表后在本地匹配 `planId`(保持后端零新增端点)。副作用:方案被审核后不再出现在列表内,此时提示「方案不存在或已被审核」。
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="card">
+      <text class="title">{{ planTitle }}</text>
+      <text class="meta">创建于 {{ formatTime(plan.createdAt) }}</text>
+      <text class="meta" v-if="plan.teacherId">审核规划师:{{ plan.teacherId }}</text>
+      <text class="meta" v-else>未分配规划师</text>
+    </view>
+
+    <view class="card">
+      <text class="label">方案正文</text>
+      <textarea
+        class="editor"
+        v-model="planContent"
+        :disabled="!editing"
+        placeholder="方案正文"
+      />
+    </view>
+
+    <view class="actions">
+      <view class="btn ghost" @click="toggleEdit">{{ editing ? '取消编辑' : '编辑' }}</view>
+      <view class="btn ghost" v-if="editing" @click="save">保存</view>
+      <view class="btn ok" @click="confirmThen('approve')">通过</view>
+      <view class="btn no" @click="confirmThen('reject')">驳回</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  updateGuidePlanContent,
+  approveGuidePlan,
+  rejectGuidePlan,
+  getGuidePendingPlans
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      planId: null,
+      plan: {},
+      planContent: '',
+      editing: false
+    }
+  },
+  computed: {
+    planTitle() {
+      // HealthPlan 无 title 字段
+      return this.plan.goal || this.plan.memberName || '健康方案'
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    this.planId = options.planId
+    this.fetchPlan()
+  },
+  methods: {
+    async fetchPlan() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          const list = res.data || []
+          for (let i = 0; i < list.length; i++) {
+            if (String(list[i].id) === String(this.planId)) {
+              this.plan = list[i]
+              this.planContent = list[i].planContent || ''
+              return
+            }
+          }
+          uni.showToast({ title: '方案不存在或已被审核', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }
+    },
+    toggleEdit() {
+      this.editing = !this.editing
+    },
+    async save() {
+      try {
+        const res = await updateGuidePlanContent(this.familyId, this.planId, this.planContent)
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已保存', icon: 'none' })
+          this.editing = false
+        } else {
+          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    confirmThen(action) {
+      uni.showModal({
+        title: action === 'approve' ? '确认通过?' : '确认驳回?',
+        content: action === 'approve' ? '通过后将自动生成每日任务' : '驳回后家长需重新提交',
+        success: (r) => {
+          if (!r.confirm) return
+          this.doAction(action, '')
+        }
+      })
+    },
+    async doAction(action, comment) {
+      try {
+        const fn = action === 'approve' ? approveGuidePlan : rejectGuidePlan
+        const res = await fn(this.familyId, this.planId, comment)
+        if (res && res.code === 200) {
+          const d = res.data || {}
+          if (action === 'approve' && d.taskGenerated === false) {
+            uni.showModal({
+              title: '部分成功',
+              content: d.message || '方案已发布,但未生成任何任务,请检查方案内容',
+              showCancel: false
+            })
+          } else {
+            uni.showToast({ title: '操作成功', icon: 'none' })
+          }
+          setTimeout(() => uni.navigateBack(), 1200)
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).substring(0, 19).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.title { font-size: 32rpx; font-weight: 600; color: #1F2D3D; display: block; margin-bottom: 12rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; display: block; margin-bottom: 6rpx; }
+.label { font-size: 26rpx; color: #5A6478; display: block; margin-bottom: 12rpx; }
+.editor { width: 100%; min-height: 400rpx; font-size: 28rpx; color: #1F2D3D; padding: 16rpx; background: #F7F9FC; border-radius: 12rpx; }
+.actions { display: flex; flex-direction: row; flex-wrap: wrap; }
+.btn { flex: 1; text-align: center; padding: 20rpx 0; border-radius: 12rpx; font-size: 28rpx; margin-right: 16rpx; margin-bottom: 16rpx; min-width: 40%; }
+.btn.ghost { background: #fff; color: #4A9BD7; border: 1rpx solid #4A9BD7; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+</style>
+```
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/plan-detail.vue
+```
+
+预期:`OK`。
+
+- [ ] **步骤 3:违规写法扫描**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/plan-detail.vue || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/plan-detail.vue
+git commit -m "feat(frontend): 新增方案详情页,支持编辑/通过/驳回与部分成功提示"
+```
+
+---
+
+### 任务 13:注册页面并加入入口
+
+**文件:**
+- 修改:`cfc-frontend/pages.json`
+- 修改:`cfc-frontend/pages/teacher/families.vue`
+
+**关键事实:** `pages/teacher` 在 `pages.json` 中是**分包**(`"root": "pages/teacher"`),其页面 `path` 写**裸名**(如 `"path": "index"`),不能写 `"pages/teacher/xxx"`。
+
+**关键事实:** `pages/teacher/index.vue` 已废弃(文件头注释声明 2026-06-15 起废弃,无路由访问),**入口必须加在 `families.vue`**(该页已 import `getBoundFamilies`,是规划师家庭管理的实际入口)。
+
+- [ ] **步骤 1:在分包内注册 3 个页面**
+
+在 `pages.json` 的 `pages/teacher` 分包 `"pages"` 数组中新增(裸 path):
+
+```json
+        {
+          "path": "review-workbench",
+          "style": {
+            "navigationBarTitleText": "审核工作台"
+          }
+        },
+        {
+          "path": "review-family",
+          "style": {
+            "navigationBarTitleText": "家庭审核"
+          }
+        },
+        {
+          "path": "plan-detail",
+          "style": {
+            "navigationBarTitleText": "方案详情"
+          }
+        },
+```
+
+- [ ] **步骤 2:校验 pages.json 合法性**
+
+已核实 `pages.json` **不含 `//` 注释**(`grep -c "//"` 结果为 0),可直接 `JSON.parse`:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('JSON OK')"
+```
+
+预期:`JSON OK`。
+
+- [ ] **步骤 3:在 families.vue 加入入口**
+
+先读现有结构:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && sed -n '1,40p' pages/teacher/families.vue
+```
+
+在页面顶部(`<view class="page">` 内、筛选栏之前)加入入口卡片,沿用既有卡片样式类:
+
+```vue
+      <view class="family-card entry" @click="goWorkbench">
+        <text class="family-name">审核工作台</text>
+        <text class="family-meta">查看待审核方案与任务</text>
+      </view>
+```
+
+并在 `methods` 中新增:
+
+```js
+    goWorkbench() {
+      uni.navigateTo({ url: '/pages/teacher/review-workbench' })
+    },
+```
+
+样式复用既有 `.family-card`;如需强调色,加一条 `.entry { border-left: 8rpx solid #4A9BD7; }`。
+
+- [ ] **步骤 4:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/families.vue pages/teacher/review-workbench.vue pages/teacher/review-family.vue pages/teacher/plan-detail.vue
+```
+
+预期:4 行全部 `OK`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages.json cfc-frontend/pages/teacher/families.vue
+git commit -m "feat(frontend): 注册审核工作台 3 页面并在规划师家庭页加入入口"
+```
+
+---
+
+### 任务 14:文档同步
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`
+
+- [ ] **步骤 1:API_REFERENCE 新增 4.47**
+
+在 `### 4.46` 之后新增 `### 4.47 规划师审核工作台(/api/guide/families/*)`,记录 6 个新端点:
+
+| 端点 | 权限 |
+|---|---|
+| `POST /api/guide/families/my-families` | teacher / admin |
+| `POST /api/guide/families/{familyId}/members` | teacher 需 isBound;admin 放行 |
+| `POST /api/guide/families/{familyId}/plans/pending` | teacher 需 isBound;admin 可传 teacherId 过滤 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/update` | 同上 + planId IDOR 反查 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/approve` | 同上 + planId IDOR 反查;返回 `PlanApproveResultDTO` |
+| `POST /api/guide/families/{familyId}/plans/{planId}/reject` | 同上 + planId IDOR 反查 |
+
+同时在 4.46 条目下追加一行变更说明:
+
+```markdown
+> ⚠️ 2026-10-04:`/pending-review/approve` 响应由 `Result<HealthPlan>` 改为 `Result<PlanApproveResultDTO>`(新增 `taskGenerated` / `taskCount` / `message` 字段)。该端点无前端消费者,变更安全。
+```
+
+同时记录既有任务端点已补绑定校验:
+
+```markdown
+> ✅ 2026-10-04:`/{familyId}/overview`、`/{familyId}/children/{memberId}/tasks`、`/{familyId}/children/{memberId}/pending-review`、`/{familyId}/tasks/{taskId}/review`、`/{familyId}/tasks/batch-review`、`/{familyId}/tasks/{taskId}`、`/{familyId}/tasks/{taskId}/delete` 共 7 个端点已接入 `guide_families` 绑定校验,修复跨家庭越权。
+```
+
+- [ ] **步骤 2:PROJECT-OVERVIEW 更新状态**
+
+在 `docs/superpowers/PROJECT-OVERVIEW.md` 的 specs 索引表中,把 `2026-10-04-plan-review-workbench-design.md` 的状态更新为 `🟢 已实施`,并在说明末尾追加实现计划路径 `plans/2026-10-04-plan-review-workbench.md`。
+
+同时新增一条 plans 索引条目指向 `plans/2026-10-04-plan-review-workbench.md`(若该表尚无 plans 索引,则补一节)。
+
+- [ ] **步骤 3:确认两处已同步**
+
+```bash
+cd /sc-data/cfc && grep -n "4.47 规划师审核工作台" docs/superpowers/api/API_REFERENCE.md && grep -n "2026-10-04-plan-review-workbench" docs/superpowers/PROJECT-OVERVIEW.md
+```
+
+预期:两行均命中。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 补充审核工作台 API 4.47 并更新 PROJECT-OVERVIEW 状态"
+```
+
+---
+
+## 最终验证
+
+- [ ] **步骤 1:后端编译 + 测试编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 2:新增单测全绿**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:.*Failures|BUILD" | tail -8
+```
+
+预期:全部 `Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 3:前端全部校验**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node --check utils/api.js && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue pages/teacher/review-family.vue pages/teacher/plan-detail.vue pages/teacher/families.vue && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('JSON OK')"
+```
+
+预期:无报错,4 行 `OK` + `JSON OK`。
+
+- [ ] **步骤 4:小程序禁用写法全域复扫**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/review-*.vue cfc-frontend/pages/teacher/plan-detail.vue || echo "无违规"
+```
+
+预期:`无违规`。
+
+- [ ] **步骤 5:路由无重复**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:无输出。
+
+- [ ] **步骤 6:确认未改动禁区**
+
+```bash
+cd /sc-data/cfc && git diff --name-only HEAD~14 HEAD | grep -E "chat\.vue|schema\.sql|DatabaseInitializer" || echo "禁区未改动"
+```
+
+预期:`禁区未改动`。
+
+---
+
+## 计划自检
+
+**规格覆盖度:**
+
+| 规格章节 | 对应任务 |
+|---|---|
+| 2.2 越权(7 端点) | 任务 1、3 |
+| 2.3 confirmBind 重绑 | 任务 2 |
+| 2.4 rejectPlan 守卫 | 任务 4 |
+| 2.5 taskGenerated 静默 | 任务 5 |
+| 3.1 授权源 guide_families | 任务 1、7 |
+| 3.2 新增 plans/* 端点 | 任务 6 |
+| 3.3 未分配池仅 admin 可见 | 任务 6(`teacherId` 过滤 + isBound) |
+| 3.4 首页不用 bound-families | 任务 7(my-families) |
+| 3.6 成员列表新增端点 | 任务 7 |
+| 4.2 授权规则收敛 | 任务 1 |
+| 4.3 IDOR 防护 | 任务 4(`getPlanForFamily`)、任务 6(`resolvePlan`) |
+| 4.4 列表查询条件 | 任务 6(复用 `listPendingReviewPlans`) |
+| 5.1-5.3 三个页面 | 任务 10、11、12 |
+| 6 数据流 | 任务 10、11、12 |
+| 7 错误处理 | 任务 6(403)、任务 5(部分成功)、任务 12(提示) |
+| 8 缺陷清单 4 项 | 任务 2、3、4、5 |
+| 9 测试要求 | 任务 0-8 |
+| 11 文档同步 | 任务 14 |
+
+**已知不在本计划内**(规格第 10 节已声明另开任务):A2 提醒/消息中心、A3 测评链路断点、B1 合伙人分佣、B2 免费/收费合同、`InviteCardService:237` 子串匹配、`teacher_family_ids` 解绑不撤销、`guide_families` 数据补齐迁移、admin 指派端点。
+
+**本次修订相对初版修正的事实错误(已逐条核实):**
+
+1. `ParamUtils` 包名为 `com.etotem.cfc.util`(初版误写 `ccfc`)。
+2. `node --check` 不能解析 `.vue`(`ERR_UNKNOWN_FILE_EXTENSION`),初版前端校验手段全部失效 → 改用 `vue-template-compiler`。
+3. `pages/teacher` 是分包,页面 `path` 须写裸名,初版写成完整路径会导致注册失败。
+4. 入口页 `pages/teacher/index.vue` 已废弃,初版要求改它 → 改为 `families.vue`。
+5. `HealthPlan` 实体**无** `title` 字段,初版 `p.title` 永远为空。
+6. `Task` 实体**无** `dueDate`,截止时间是 `deadline`。
+7. `TaskReviewDTO` 审核字段是 `approved`(Boolean),初版发 `{action, comment}` 后端收不到。
+8. 小程序 userId 应取 `uni.getStorageSync('userId')`,初版 `getApp().globalData.userId` 不可靠。
+9. `my-families` 若走 `checkFamilyAccess(role, userId, null)`,teacher 会因 `familyId == null` 被拒 → 改为显式角色判断。
+10. `my-families` 缺 `familyName` 返回 → Service 增查 `Family.name`(字段名是 `name` 不是 `familyName`)。
+11. 初版 `GuidePlanReviewController` 在 Controller 直接注入 `HealthPlanMapper`,违反项目禁令 → 改为 Service 的 `getPlanForFamily`。
+12. 初版 guard 只做角色校验,接入既有端点会改变其 500/`Access denied` 语义并使 `GuideFamilyTaskControllerTest` 全红 → 拆为 `checkBinding` / `checkFamilyAccess` 双方法。
+13. `GuideFamilyTaskController` 中只有 `/{familyId}/overview` 已有 `userId` 参数,其余 6 个需补,初版笼统写「补 role/userId」。
+14. 改 6 个方法签名会破坏既有 `GuideFamilyTaskControllerTest`(直接调用控制器方法),初版完全遗漏该文件。
+15. `ProductControllerTest` 实测报错 8 处(行 52/55/79/82/103/106/125/128),初版误写 6 处且漏掉 52/55。
+16. `generateDailyTasksFromPlan` 返回 `void` 且有 2 处提前 return、被 3 处调用,初版「改成返回 int」风险过高 → 改为生成后查库统计。
+17. `src/test/resources` 为空,`@SpringBootTest` 连真实 DB,`mvn test` 全量不可能稳定 `BUILD SUCCESS` → 改用分层验证口径。