# 规划师审核工作台(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 -DskipTests=false -Dtest=<新增用例>` | 纯 Mockito,不碰 DB,可稳定通过 | | 前端 | `vue-check.js`(见下) | 真实 SFC 解析 + 模板编译 + script 语法检查 | **禁止**把 `mvn test` 全量 `BUILD SUCCESS` 当作验收标准。 > ⚠️ **`-DskipTests=false` 不可省略(本计划实测踩坑)** > > `pom.xml:28` 定义了 `true`,且 surefire 在 `pom.xml:220` 以 `${skipTests}` 消费它。 > 因此 **`mvn test` 默认根本不跑测试**,输出 `BUILD SUCCESS` + `[INFO] Tests are skipped.` + 不生成 `target/surefire-reports/`。 > 这是「静默假通过」陷阱:不加开关时,单测即使全红也会报 BUILD SUCCESS。 > 每次跑单测必须显式 `-DskipTests=false`,并确认输出含 `Tests run: N, Failures: 0, Errors: 0`。 > 注意 `mvn test-compile` **不受**该开关影响(编译测试代码始终执行),故任务 0 的验证命令无需加开关。 ### 前端验证脚本 `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`;`Result.success()` code=200,`Result.error(msg)` code=500,`Result.error(code, msg)` 自定义 - Controller 内手动检查 `@RequestAttribute("role")` - 请求参数用 `@RequestBody Map 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 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 -DskipTests=false -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 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 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 -DskipTests=false -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 -DskipTests=false -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 -DskipTests=false -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 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 denied = requireBound(guideId, familyId); // ← 插在这行下面 if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); ``` > ⚠️ **不要**把 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_匹配返回方案() { HealthPlan plan = planWithStatus("pending_review"); plan.setId(99L); when(mapper.selectById(99L)).thenReturn(plan); 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 -DskipTests=false -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 -DskipTests=false -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 -DskipTests=false -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 generated = taskMapper.selectList(new QueryWrapper() .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 approvePlan( @RequestBody Map 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 -DskipTests=false -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 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 -DskipTests=false -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> listPendingPlans( @RequestBody(required = false) Map params, @RequestAttribute("role") String role, @RequestAttribute("userId") Long userId, @PathVariable("familyId") Long familyId) { Result denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); 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 updatePlan( @RequestBody(required = false) Map params, @RequestAttribute("role") String role, @RequestAttribute("userId") Long userId, @PathVariable("familyId") Long familyId, @PathVariable("planId") Long planId) { Result denied = resolvePlan(role, userId, familyId, planId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); 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 approvePlan( @RequestBody(required = false) Map params, @RequestAttribute("role") String role, @RequestAttribute("userId") Long userId, @PathVariable("familyId") Long familyId, @PathVariable("planId") Long planId) { Result denied = resolvePlan(role, userId, familyId, planId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); String comment = params == null ? null : (String) params.get("comment"); return Result.success(healthPlanService.approveAndPublish(planId, userId, comment)); } @Operation(summary = "驳回方案") @PostMapping("/{planId}/reject") public Result rejectPlan( @RequestBody(required = false) Map params, @RequestAttribute("role") String role, @RequestAttribute("userId") Long userId, @PathVariable("familyId") Long familyId, @PathVariable("planId") Long planId) { Result denied = resolvePlan(role, userId, familyId, planId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); 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 resolvePlan(String role, Long userId, Long familyId, Long planId) { Result denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId); if (plan == null) { return Result.error(403, "无权访问该方案"); } // 用反查出的真实 familyId 再校验一次,防止路径与库内不一致绕过 Result 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 -DskipTests=false -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> listMyBoundFamilies(Long guideId) { List bindings = guideFamilyMapper.selectList( new QueryWrapper() .eq("guide_id", guideId) .eq("status", "binding") .orderByDesc("bound_at")); if (bindings == null || bindings.isEmpty()) { return Collections.emptyList(); } List familyIds = bindings.stream() .map(GuideFamily::getFamilyId) .filter(java.util.Objects::nonNull) .distinct() .collect(Collectors.toList()); Map nameByFamily = new HashMap<>(); if (!familyIds.isEmpty()) { List families = familyMapper.selectBatchIds(familyIds); if (families != null) { for (Family f : families) { nameByFamily.put(f.getId(), f.getName()); } } } Map memberCountByFamily = new LinkedHashMap<>(); if (!familyIds.isEmpty()) { List members = familyMemberMapper.selectList( new QueryWrapper().in("family_id", familyIds)); if (members != null) { for (FamilyMember m : members) { Long fid = m.getFamilyId(); memberCountByFamily.merge(fid, 1, Integer::sum); } } } List> out = new ArrayList<>(); for (GuideFamily gf : bindings) { Long fid = gf.getFamilyId(); Map 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 listFamilyMembers(Long familyId) { return familyMemberMapper.selectList( new QueryWrapper().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> 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> 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 -DskipTests=false -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 -DskipTests=false -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>> 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> familyMembers( @RequestAttribute("role") String role, @RequestAttribute("userId") Long userId, @PathVariable("familyId") Long familyId) { Result denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId); if (denied != null) return Result.error(denied.getCode(), denied.getMessage()); 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 -DskipTests=false -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 ``` - [ ] **步骤 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 ``` **关键修正(相对常见错误写法):** - `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