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