2026-10-04-plan-review-workbench.md 103 KB

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

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
  • DI 用 @Resource,字段名必须等于类型默认 Bean Name(如 guideFamilyService)
  • 响应统一 Result<T>;Result.success() code=200,Result.error(msg) code=500,Result.error(code, msg) 自定义
  • Controller 内手动检查 @RequestAttribute("role")
  • 请求参数用 @RequestBody Map<String,Object> params + ParamUtils.getLong(...)(com.etotem.cfc.util.ParamUtils)
  • 禁止在 Controller 直接操作 Mapper
  • Lombok 可用(@Data、@Slf4j 已在项目中广泛使用)
  • 小程序:禁可选链 ?.、禁 CSS Grid(用 flexbox)、:key 用方法调用、时间用 substring(0,19) 且 T 换空格
  • 实体字段名务必核对,不要臆造(已核实:Family.name 不是 familyName;HealthPlan 无 title 字段;Task 无 dueDate,是 deadline)

关键事实(已核实,勿再假设)

事实 值
GuideFamilyService.isBound(guideId, familyId) 已存在,GuideFamilyService.java:152
GuideFamilyService 注入字段名 guideFamilyMapper
generateDailyTasksFromPlan(HealthPlan) 返回 void,内部有 2 处提前 return,被 3 处调用(122/210/594)→ 不改返回类型
approveAndPublish HealthPlanServiceImpl.java:581,已有状态守卫「方案状态不允许审核」
rejectPlan HealthPlanServiceImpl.java:602,无状态守卫(本次修复目标)
updatePlanContent HealthPlanServiceImpl.java:567,已有守卫「方案状态不允许编辑」
listPendingReviewPlans(familyId, teacherId) 已有 teacher_id = ? OR teacher_id IS NULL 过滤 + created_at DESC
HealthPlanServiceImpl 注入字段 healthPlanMapper、taskMapper、familyMapper、userMapper、familyMemberMapper、selfCheckMapper
GuideFamilyTaskController 基础路径 /api/guide/families,共 10 个端点
TaskReviewDTO 字段 result、aiSuggestion、overrideAi、approved(Boolean)、comment → 审核用 approved,不是 action
request 签名 request(url, method = 'POST', data = {}, options = {})
小程序 userId 来源 uni.getStorageSync('userId')
pages/teacher 路由形态 pages.json 中是 分包("root": "pages/teacher"),页面 path 写裸名,如 "path": "index"
pages/teacher/index.vue 已废弃(2026-06-15 注释声明,保留仅参考,无路由访问)→ 入口改加在 families.vue
TestRoles.assertRoleDenied 断言 code=500 且 message 含 Access denied

文件结构

后端

文件 动作 职责
service/GuideFamilyAccessGuard.java 创建 授权判定收敛:checkBinding(仅归属)+ checkFamilyAccess(角色+归属)
service/GuideFamilyService.java 修改 confirmBind 重绑时复位 status/boundAt
service/HealthPlanService.java 修改 新增 getPlanForFamily;approveAndPublish 返回类型改 DTO
service/impl/HealthPlanServiceImpl.java 修改 rejectPlan 补守卫;approveAndPublish 暴露 taskGenerated;实现 getPlanForFamily
controller/guide/GuideFamilyTaskController.java 修改 7 个带 {familyId} 端点接入 guard
controller/guide/GuidePlanReviewController.java 创建 方案审核 4 端点
controller/guide/GuideFamilyQueryController.java 创建 my-families、{familyId}/members 2 端点
dto/PlanApproveResultDTO.java 创建 approve 结果承载 taskGenerated
controller/HealthPlanController.java 修改 跟随 approveAndPublish 新返回类型

前端

文件 动作 职责
utils/api.js 修改 8 个新封装(6 新端点 + 2 任务审核)
pages/teacher/review-workbench.vue 创建 审核台首页:我的服务家庭列表
pages/teacher/review-family.vue 创建 单家庭双 Tab(方案 / 任务)
pages/teacher/plan-detail.vue 创建 方案全文 + 编辑 + 通过/驳回
pages.json 修改 在 pages/teacher 分包内注册 3 个新页面(裸 path)
pages/teacher/families.vue 修改 加入「审核工作台」入口(不是 已废弃的 index.vue)

测试

文件 动作
src/test/.../integration/controller/ProductControllerTest.java 修改(任务 0 解锁 test-compile)
src/test/.../orchestration/OrchestrationEngineTest.java 修改(任务 0 解锁 test-compile)
src/test/.../integration/controller/GuideFamilyTaskControllerTest.java 修改(任务 3:签名适配 + MockBean guard)
src/test/.../unit/GuideFamilyAccessGuardTest.java 创建
src/test/.../unit/GuideFamilyServiceBindTest.java 创建
src/test/.../unit/HealthPlanReviewGuardTest.java 创建
src/test/.../unit/GuidePlanReviewControllerAuthTest.java 创建
src/test/.../unit/GuideFamilyQueryServiceTest.java 创建

任务 0:修复既有测试文件签名漂移(前置,解锁 test-compile)

实测基线:mvn test-compile 恰好 2 个文件报错,共 10 处。

文件:

  • 修改:cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
  • 修改:cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java

  • [ ] 步骤 1:确认失败基线

    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。

先确认实际签名与待改行:

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")
  • 步骤 3:修 OrchestrationEngineTest 的 void 返回值漂移

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 的签名漂移"
    

任务 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:

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。

  • 步骤 3:创建 guard 实现

创建 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 统一家庭访问授权判定"
    

任务 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,重绑形同虚设。

先读取当前实现确认行号:

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:

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"。

  • 步骤 3:修复 confirmBind

把 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,修复解绑后无法重新绑定"
    

任务 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; 之后新增:

    @Resource
    private GuideFamilyAccessGuard guideFamilyAccessGuard;

import 区新增:

import com.etotem.cfc.service.GuideFamilyAccessGuard;
  • 步骤 2:加统一调用辅助方法

在类的最后(最后一个 } 之前)新增:

    /**
     * 校验当前规划师是否为该家庭的绑定规划师。
     * 既有端点自带角色校验(500 / Access denied),此处只补家庭归属校验。
     *
     * @return null 表示放行;非 null 为 403 响应
     */
    private Result<Void> requireBound(Long guideId, Long familyId) {
        return guideFamilyAccessGuard.checkBinding(guideId, familyId);
    }
  • 步骤 3:为 6 个端点补 userId 参数

对下表 6 个端点,在签名中补 @RequestAttribute("userId") Long guideId(/{familyId}/overview 已有,跳过):

行号 端点
245 /{familyId}/children/{memberId}/tasks
270 /{familyId}/children/{memberId}/pending-review
290 /{familyId}/tasks/{taskId}/review
315 /{familyId}/tasks/batch-review
372 /{familyId}/tasks/{taskId}
423 /{familyId}/tasks/{taskId}/delete
  • 步骤 4:为 7 个端点接入 guard 调用

插入位置(关键):紧跟在既有角色校验这一行之后、任何业务查询/写库之前:

        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。

  • 步骤 5:适配既有测试 GuideFamilyTaskControllerTest

该测试是 @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 个端点接入绑定校验,修复跨家庭越权"
    

任务 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:

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。

  • 步骤 3:补 rejectPlan 状态守卫

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("方案状态不允许审核");
        }
  • 步骤 4:新增 getPlanForFamily

接口 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 反查"
    

任务 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:

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):

    @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。

  • 步骤 4:改造 approveAndPublish

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。

  • 步骤 5:同步接口签名与既有调用方

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,任务生成失败不再静默"
    

任务 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:

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。

  • 步骤 3:创建控制器

创建 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 反查防护"
    

任务 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:

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.etotem.cfc.entity.Family;
import com.etotem.cfc.entity.FamilyMember;
import com.etotem.cfc.entity.GuideFamily;
import com.etotem.cfc.mapper.FamilyMapper;
import com.etotem.cfc.mapper.FamilyMemberMapper;
import com.etotem.cfc.mapper.GuideFamilyMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class GuideFamilyQueryService {

    @Resource
    private GuideFamilyMapper guideFamilyMapper;

    @Resource
    private FamilyMapper familyMapper;

    @Resource
    private FamilyMemberMapper familyMemberMapper;

    /**
     * 我的服务家庭:授权源与 isBound() 同为 guide_families(status='binding'),
     * 保证列表与授权判定同源,避免「列表里有、点进去 403」。
     */
    public List<Map<String, Object>> listMyBoundFamilies(Long guideId) {
        List<GuideFamily> bindings = guideFamilyMapper.selectList(
                new QueryWrapper<GuideFamily>()
                        .eq("guide_id", guideId)
                        .eq("status", "binding")
                        .orderByDesc("bound_at"));
        if (bindings == null || bindings.isEmpty()) {
            return Collections.emptyList();
        }

        List<Long> familyIds = bindings.stream()
                .map(GuideFamily::getFamilyId)
                .filter(java.util.Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());

        Map<Long, String> nameByFamily = new HashMap<>();
        if (!familyIds.isEmpty()) {
            List<Family> families = familyMapper.selectBatchIds(familyIds);
            if (families != null) {
                for (Family f : families) {
                    nameByFamily.put(f.getId(), f.getName());
                }
            }
        }

        Map<Long, Integer> memberCountByFamily = new LinkedHashMap<>();
        if (!familyIds.isEmpty()) {
            List<FamilyMember> members = familyMemberMapper.selectList(
                    new QueryWrapper<FamilyMember>().in("family_id", familyIds));
            if (members != null) {
                for (FamilyMember m : members) {
                    Long fid = m.getFamilyId();
                    memberCountByFamily.merge(fid, 1, Integer::sum);
                }
            }
        }

        List<Map<String, Object>> out = new ArrayList<>();
        for (GuideFamily gf : bindings) {
            Long fid = gf.getFamilyId();
            Map<String, Object> row = new HashMap<>();
            row.put("familyId", fid);
            row.put("familyName", nameByFamily.get(fid));
            row.put("serviceType", gf.getServiceType());
            row.put("servicePrice", gf.getServicePrice());
            row.put("boundAt", gf.getBoundAt());
            row.put("memberCount", memberCountByFamily.getOrDefault(fid, 0));
            out.add(row);
        }
        return out;
    }

    /** 客户家庭成员列表(显式 familyId,由调用方先做绑定校验) */
    public List<FamilyMember> listFamilyMembers(Long familyId) {
        return familyMemberMapper.selectList(
                new QueryWrapper<FamilyMember>().eq("family_id", familyId));
    }
}

注意: 家庭名字段是 Family.name(不是 familyName)。已核实 Family 实体字段:id、name、inviteCode、streetId、province、city、district、street、teacherId、butlerId、creatorId、createdAt、updatedAt。

  • 步骤 2:编写失败的测试

创建 cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java:

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。

  • 步骤 5:创建 Controller

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): 新增我的服务家庭与客户家庭成员查询端点"
    

任务 8:后端验证

  • [ ] 步骤 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 后端编译与新增单测验证通过"
    

任务 9:前端 API 封装

文件:

  • 修改: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 封装"
    

任务 10:review-workbench.vue 审核台首页

文件:

  • 创建: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): 新增规划师审核工作台首页"
    

任务 11:review-family.vue 单家庭双 Tab

文件:

  • 创建: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。
  • userId 用 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 审核页"
    

任务 12:plan-detail.vue 方案详情

文件:

  • 创建: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): 新增方案详情页,支持编辑/通过/驳回与部分成功提示"
    

任务 13:注册页面并加入入口

文件:

  • 修改:cfc-frontend/pages.json
  • 修改:cfc-frontend/pages/teacher/families.vue

关键事实: pages/teacher 在 pages.json 中是分包("root": "pages/teacher"),其页面 path 写裸名(如 "path": "index"),不能写 "pages/teacher/xxx"。

关键事实: pages/teacher/index.vue 已废弃(文件头注释声明 2026-06-15 起废弃,无路由访问),入口必须加在 families.vue(该页已 import getBoundFamilies,是规划师家庭管理的实际入口)。

  • 步骤 1:在分包内注册 3 个页面

在 pages.json 的 pages/teacher 分包 "pages" 数组中新增(裸 path):

        {
          "path": "review-workbench",
          "style": {
            "navigationBarTitleText": "审核工作台"
          }
        },
        {
          "path": "review-family",
          "style": {
            "navigationBarTitleText": "家庭审核"
          }
        },
        {
          "path": "plan-detail",
          "style": {
            "navigationBarTitleText": "方案详情"
          }
        },
  • 步骤 2:校验 pages.json 合法性

已核实 pages.json 不含 // 注释(grep -c "//" 结果为 0),可直接 JSON.parse:

cd /sc-data/cfc/cfc-frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('JSON OK')"

预期:JSON OK。

  • 步骤 3:在 families.vue 加入入口

先读现有结构:

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 页面并在规划师家庭页加入入口"
    

任务 14:文档同步

文件:

  • 修改:docs/superpowers/api/API_REFERENCE.md
  • 修改:docs/superpowers/PROJECT-OVERVIEW.md

  • [ ] 步骤 1:API_REFERENCE 新增 4.47

在 ### 4.46 之后新增 ### 4.47 规划师审核工作台(/api/guide/families/*),记录 6 个新端点:

端点 权限
POST /api/guide/families/my-families teacher / admin
POST /api/guide/families/{familyId}/members teacher 需 isBound;admin 放行
POST /api/guide/families/{familyId}/plans/pending teacher 需 isBound;admin 可传 teacherId 过滤
POST /api/guide/families/{familyId}/plans/{planId}/update 同上 + planId IDOR 反查
POST /api/guide/families/{familyId}/plans/{planId}/approve 同上 + planId IDOR 反查;返回 PlanApproveResultDTO
POST /api/guide/families/{familyId}/plans/{planId}/reject 同上 + planId IDOR 反查

同时在 4.46 条目下追加一行变更说明:

> ⚠️ 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` 绑定校验,修复跨家庭越权。
  • 步骤 2:PROJECT-OVERVIEW 更新状态

在 docs/superpowers/PROJECT-OVERVIEW.md 的 specs 索引表中,把 2026-10-04-plan-review-workbench-design.md 的状态更新为 🟢 已实施,并在说明末尾追加实现计划路径 plans/2026-10-04-plan-review-workbench.md。

同时新增一条 plans 索引条目指向 plans/2026-10-04-plan-review-workbench.md(若该表尚无 plans 索引,则补一节)。

  • [ ] 步骤 3:确认两处已同步

    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 指派端点。

本次修订相对初版修正的事实错误(已逐条核实):

  1. ParamUtils 包名为 com.etotem.cfc.util(初版误写 ccfc)。
  2. node --check 不能解析 .vue(ERR_UNKNOWN_FILE_EXTENSION),初版前端校验手段全部失效 → 改用 vue-template-compiler。
  3. pages/teacher 是分包,页面 path 须写裸名,初版写成完整路径会导致注册失败。
  4. 入口页 pages/teacher/index.vue 已废弃,初版要求改它 → 改为 families.vue。
  5. HealthPlan 实体无 title 字段,初版 p.title 永远为空。
  6. Task 实体无 dueDate,截止时间是 deadline。
  7. TaskReviewDTO 审核字段是 approved(Boolean),初版发 {action, comment} 后端收不到。
  8. 小程序 userId 应取 uni.getStorageSync('userId'),初版 getApp().globalData.userId 不可靠。
  9. my-families 若走 checkFamilyAccess(role, userId, null),teacher 会因 familyId == null 被拒 → 改为显式角色判断。
  10. my-families 缺 familyName 返回 → Service 增查 Family.name(字段名是 name 不是 familyName)。
  11. 初版 GuidePlanReviewController 在 Controller 直接注入 HealthPlanMapper,违反项目禁令 → 改为 Service 的 getPlanForFamily。
  12. 初版 guard 只做角色校验,接入既有端点会改变其 500/Access denied 语义并使 GuideFamilyTaskControllerTest 全红 → 拆为 checkBinding / checkFamilyAccess 双方法。
  13. GuideFamilyTaskController 中只有 /{familyId}/overview 已有 userId 参数,其余 6 个需补,初版笼统写「补 role/userId」。
  14. 改 6 个方法签名会破坏既有 GuideFamilyTaskControllerTest(直接调用控制器方法),初版完全遗漏该文件。
  15. ProductControllerTest 实测报错 8 处(行 52/55/79/82/103/106/125/128),初版误写 6 处且漏掉 52/55。
  16. generateDailyTasksFromPlan 返回 void 且有 2 处提前 return、被 3 处调用,初版「改成返回 int」风险过高 → 改为生成后查库统计。
  17. src/test/resources 为空,@SpringBootTest 连真实 DB,mvn test 全量不可能稳定 BUILD SUCCESS → 改用分层验证口径。
  18. guard 返回 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 与文案。
  19. 改 6 个方法签名会破坏两个测试文件:除修订 14 已列的 GuideFamilyTaskControllerTest(15 处调用)外,还有 GuideRolePermissionTest(4 处调用),修订 14 仍遗漏 → 两个文件都需补 @MockBean GuideFamilyAccessGuard(Mockito 默认返回 null 等价放行,既有用例断言不受影响)。
  20. 任务 4 测试辅助方法 planWithStatus 固定 setId(5L),而 getPlanForFamily_匹配返回方案 用 selectById(99L) 打桩却断言 getId()==99 → 必然失败(实测 expected: <99> but was: <5>)→ 该用例需显式 plan.setId(99L) 后再打桩。注意同一辅助方法在 不属于该家庭返回null 用例中无害(只断言 null)。
  21. 任务 5 步骤 3 预期「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)。
  22. 任务 5 步骤 5 的安全性依据「approvePlan 无任何页面引用」不准确:cfc-frontend/pages/health/health-plan-summary.vue:272 确有 import { ... approvePlan }。但全仓检索 approvePlan( 无任何调用点(死导入),故改返回类型确实不破坏前端。正确依据是「无调用」而非「无引用」。
  23. 任务 6 resolvePlan 的注释「用反查出的真实 familyId 再校验一次,防止路径与库内不一致绕过」事实错误:getPlanForFamily(planId, familyId) 本身已按 familyId 过滤,plan.getFamilyId() 必然等于上一行已通过 checkFamilyAccess 校验的 familyId,因此 recheck 永不拒绝任何请求。真正的 IDOR 防线是第一道 checkFamilyAccess + getPlanForFamily 返回 null 即 403。recheck 调用予以保留(纵深防御、零成本),但注释必须改为「兜底重复校验,勿依赖它拦截路径不一致」,防止后人误判此处存在真实防护而在其他端点放弃校验。
  24. 任务 7 的 RED 步骤(步骤 3)排在 Service 创建(步骤 1)之后,故其预期的 cannot find symbol: class GuideFamilyQueryService 不可能出现——与任务 5 缺陷 21 同型。正确顺序:先写 GuideFamilyQueryServiceTest → 确认 RED(cannot find symbol,实测报错于 import 行与字段声明行)→ 再建 Service。
  25. 任务 7 步骤 7 的路由查重命令会误报:grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d 只比较方法级字面量、忽略类级 @RequestMapping 前缀,因此 /create、/delete、/detail、/invite-code/generate、/list、/my、/stats、/update 全被报成重复,而它们分属不同控制器、完整路径并不相同。有效查重必须把类级前缀与方法级字面量拼接后按 METHOD+完整路径 比较;按此归一化校验,全仓 1688 条映射零重复。任务 8 步骤 4 复用了同一条错误命令,同样应以归一化脚本替代。
  26. 任务 9 步骤 2 的 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 只做语法解析、不解析 @/ 路径别名,故无需额外配置。