# 规划师审核工作台(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