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