ソースを参照

fix(security): GuideFamilyTaskController 7 个端点接入绑定校验,修复跨家庭越权

iwt 1 日 前
親
コミット
a120d2299b

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -14,6 +14,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskService;
@@ -50,6 +51,9 @@ public class GuideFamilyTaskController {
     @Resource
     private GuideFamilyService guideFamilyService;
 
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     /**
      * 规划师解除家庭绑定(仅限解除属于自己的绑定)
      */
@@ -180,6 +184,8 @@ public class GuideFamilyTaskController {
             @RequestAttribute("role") String role,
             @PathVariable Long familyId) {
         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());
 
         List<FamilyMember> children = familyMemberMapper.selectList(
             new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<FamilyMember>()
@@ -244,11 +250,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/children/{memberId}/tasks")
     public Result<Page<Task>> getFamilyMemberTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long memberId,
             @RequestAttribute("role") String role,
             @RequestBody(required = false) Map<String, Object> params) {
         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());
 
         Integer page = params != null && params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params != null && params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
@@ -269,11 +278,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/children/{memberId}/pending-review")
     public Result<List<Task>> getPendingReviewTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long memberId,
             @RequestAttribute("role") String role) {
 
         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());
         List<Task> tasks = taskService.getPendingReviewTasksForFamilyMember(memberId);
         return Result.success(tasks);
     }
@@ -289,12 +301,15 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}/review")
     public Result<Boolean> reviewTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role,
             @RequestBody TaskReviewDTO dto) {
 
         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());
         Result<?> memberCheck = checkMemberOnly(taskId);
         if (memberCheck != null) {
             return Result.error(memberCheck.getMessage());
@@ -314,6 +329,7 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/batch-review")
     public Result<Map<String, Object>> batchReviewTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @RequestAttribute("role") String role,
             @RequestBody Map<String, Object> params) {
@@ -330,6 +346,8 @@ public class GuideFamilyTaskController {
 
 
         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());
         int successCount = 0;
         int failCount = 0;
 
@@ -371,12 +389,15 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}")
     public Result<Boolean> updateTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role,
             @RequestBody Map<String, Object> taskData) {
         
         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());
         
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
@@ -422,11 +443,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}/delete")
     public Result<Boolean> deleteTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role) {
         
         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());
         
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
@@ -498,4 +522,11 @@ public class GuideFamilyTaskController {
         stats.put("pendingReviewTasks", pendingReview);
         return Result.success(stats);
     }
+
+    /**
+     * 家庭归属校验;返回 null 表示放行,非 null 为 403 响应。
+     */
+    private Result<Void> requireBound(Long guideId, Long familyId) {
+        return guideFamilyAccessGuard.checkBinding(guideId, familyId);
+    }
 }

+ 19 - 15
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.TaskService;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
@@ -42,6 +43,9 @@ public class GuideFamilyTaskControllerTest {
     @MockBean
     private TaskMapper taskMapper;
 
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     private User mockUser(Long id, String role, String nickname) {
         User user = new User();
         user.setId(id);
@@ -94,7 +98,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("page", 1);
         params.put("size", 10);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.getFamilyMemberTasks(100L, 20L, role, params);
+            Result<?> result = controller.getFamilyMemberTasks(1L, 100L, 20L, role, params);
             assertRoleDenied(result);
         }
     }
@@ -102,7 +106,7 @@ public class GuideFamilyTaskControllerTest {
     @Test
     public void getPendingReviewTasks_NonTeacherRole_AccessDenied() {
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.getPendingReviewTasks(100L, 20L, role);
+            Result<?> result = controller.getPendingReviewTasks(1L, 100L, 20L, role);
             assertRoleDenied(result);
         }
     }
@@ -112,7 +116,7 @@ public class GuideFamilyTaskControllerTest {
         TaskReviewDTO dto = new TaskReviewDTO();
         dto.setApproved(true);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.reviewTask(100L, 1L, role, dto);
+            Result<?> result = controller.reviewTask(1L, 100L, 1L, role, dto);
             assertRoleDenied(result);
         }
     }
@@ -123,7 +127,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("taskIds", Arrays.asList(1L, 2L));
         params.put("approved", true);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.batchReviewTasks(100L, role, params);
+            Result<?> result = controller.batchReviewTasks(1L, 100L, role, params);
             assertRoleDenied(result);
         }
     }
@@ -133,7 +137,7 @@ public class GuideFamilyTaskControllerTest {
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.updateTask(100L, 1L, role, taskData);
+            Result<?> result = controller.updateTask(1L, 100L, 1L, role, taskData);
             assertRoleDenied(result);
         }
     }
@@ -141,7 +145,7 @@ public class GuideFamilyTaskControllerTest {
     @Test
     public void deleteTask_NonTeacherRole_AccessDenied() {
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.deleteTask(100L, 1L, role);
+            Result<?> result = controller.deleteTask(1L, 100L, 1L, role);
             assertRoleDenied(result);
         }
     }
@@ -210,7 +214,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("page", 1);
         params.put("size", 10);
 
-        Result<Page<Task>> result = controller.getFamilyMemberTasks(familyId, childId, "teacher", params);
+        Result<Page<Task>> result = controller.getFamilyMemberTasks(1L, familyId, childId, "teacher", params);
         Page<Task> page = result.getData();
         assertEquals(2, page.getTotal());
         assertEquals(2, page.getRecords().size());
@@ -225,7 +229,7 @@ public class GuideFamilyTaskControllerTest {
         );
         when(taskService.getPendingReviewTasksForChild(childId)).thenReturn(pendingTasks);
 
-        Result<List<Task>> result = controller.getPendingReviewTasks(familyId, childId, "teacher");
+        Result<List<Task>> result = controller.getPendingReviewTasks(1L, familyId, childId, "teacher");
         assertEquals(1, result.getData().size());
     }
 
@@ -239,7 +243,7 @@ public class GuideFamilyTaskControllerTest {
 
         when(taskService.reviewTask(eq(taskId), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Boolean> result = controller.reviewTask(familyId, taskId, "teacher", dto);
+        Result<Boolean> result = controller.reviewTask(1L, familyId, taskId, "teacher", dto);
         assertTrue(result.getData());
     }
 
@@ -254,7 +258,7 @@ public class GuideFamilyTaskControllerTest {
 
         when(taskService.reviewTask(anyLong(), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Map<String, Object>> result = controller.batchReviewTasks(familyId, "teacher", params);
+        Result<Map<String, Object>> result = controller.batchReviewTasks(1L, familyId, "teacher", params);
         Map<String, Object> data = result.getData();
         assertEquals(3, data.get("successCount"));
         assertEquals(0, data.get("failCount"));
@@ -273,7 +277,7 @@ public class GuideFamilyTaskControllerTest {
         taskData.put("description", "新描述");
         taskData.put("points", 20);
 
-        Result<Boolean> result = controller.updateTask(familyId, taskId, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, familyId, taskId, "teacher", taskData);
         assertTrue(result.getData());
     }
 
@@ -283,7 +287,7 @@ public class GuideFamilyTaskControllerTest {
 
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
-        Result<Boolean> result = controller.updateTask(100L, 999L, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, 100L, 999L, "teacher", taskData);
         assertEquals("任务不存在", result.getMessage());
     }
 
@@ -295,7 +299,7 @@ public class GuideFamilyTaskControllerTest {
 
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
-        Result<Boolean> result = controller.updateTask(100L, taskId, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, 100L, taskId, "teacher", taskData);
         assertEquals("任务不属于该家庭", result.getMessage());
     }
 
@@ -307,14 +311,14 @@ public class GuideFamilyTaskControllerTest {
         when(taskMapper.selectById(taskId)).thenReturn(task);
         when(taskMapper.updateById(any(Task.class))).thenReturn(1);
 
-        Result<Boolean> result = controller.deleteTask(familyId, taskId, "teacher");
+        Result<Boolean> result = controller.deleteTask(1L, familyId, taskId, "teacher");
         assertTrue(result.getData());
     }
 
     @Test
     public void deleteTask_NotFound_ReturnsError() {
         when(taskMapper.selectById(999L)).thenReturn(null);
-        Result<Boolean> result = controller.deleteTask(100L, 999L, "teacher");
+        Result<Boolean> result = controller.deleteTask(1L, 100L, 999L, "teacher");
         assertEquals("任务不存在", result.getMessage());
     }
 }

+ 7 - 4
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideRolePermissionTest.java

@@ -43,6 +43,9 @@ public class GuideRolePermissionTest {
     @Resource
     private GuideFamilyTaskController guideFamilyTaskController;
 
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     @MockBean
     private TaskService taskService;
 
@@ -187,7 +190,7 @@ public class GuideRolePermissionTest {
 
         when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Boolean> result = guideFamilyTaskController.reviewTask(100L, 1L, "teacher", dto);
+        Result<Boolean> result = guideFamilyTaskController.reviewTask(1L, 100L, 1L, "teacher", dto);
         assertTrue(result.getData());
     }
 
@@ -196,7 +199,7 @@ public class GuideRolePermissionTest {
         TaskReviewDTO dto = new TaskReviewDTO();
         dto.setApproved(true);
 
-        Result<Boolean> result = guideFamilyTaskController.reviewTask(100L, 1L, "parent", dto);
+        Result<Boolean> result = guideFamilyTaskController.reviewTask(1L, 100L, 1L, "parent", dto);
         assertEquals("Access denied", result.getMessage());
     }
 
@@ -209,7 +212,7 @@ public class GuideRolePermissionTest {
 
         when(taskService.reviewTask(anyLong(), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(100L, "teacher", params);
+        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(1L, 100L, "teacher", params);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().get("successCount"));
     }
@@ -220,7 +223,7 @@ public class GuideRolePermissionTest {
         params.put("taskIds", Arrays.asList(1L));
         params.put("approved", true);
 
-        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(100L, "child", params);
+        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(1L, 100L, "child", params);
         assertEquals("Access denied", result.getMessage());
     }
 

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -383,7 +383,7 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
-| `2026-10-04-plan-review-workbench.md` | 🟡 待执行(15 任务:任务 0 修 test-compile 基线 → 任务 1-7 后端授权/守卫/审核端点 → 任务 9-13 前端 3 页面 + 分包注册 + families.vue 入口 → 任务 14 文档同步与禁区检查) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库) |
+| `2026-10-04-plan-review-workbench.md` | 🟡 进行中(15 任务,任务 0-3 已完成:0 修 test-compile 基线、1 GuideFamilyAccessGuard、2 confirmBind 重绑复位、3 七个端点接入绑定校验;任务 4-8 后端守卫/审核端点 → 9-13 前端 3 页面 + 分包注册 + families.vue 入口 → 14 文档同步与禁区检查) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库。⚠️ `pom.xml:28` 有 `<skipTests>true</skipTests>`,跑单测必须显式 `-DskipTests=false`,否则只输出 `Tests are skipped` 的假通过) |
 | `2026-09-10-report-quick-analyze.md` | 🟡 待执行(5 任务:Redis 依赖/配置 + AIChatController 抽 buildChatInputs 新增 report/analyze 端点 + api.js 封装 + report-detail 浮层 + 验证) | 报告快速分析实现计划 |
 | `2026-09-10-key-person-expansion-octopus.md` | 🟡 待执行(12 任务:迁移305-307 三表 + 3实体/Mapper/Service/Controller + 新 OctopusDiagram 三层画布 + 成效/关键人录入页 + action-detail 集成 + API 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |
 | `2026-08-18-homepage-health-funnel.md` | ✅ 已完成(登录态四段式重排 + 自测大卡 + 方法与计算属性 + 样式;未登录态零改动) | 首页健康漏斗实现计划 |

+ 10 - 8
docs/superpowers/plans/2026-10-04-plan-review-workbench.md

@@ -691,7 +691,7 @@ import com.etotem.cfc.service.GuideFamilyAccessGuard;
 ```java
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }   // ← 既有行,不动
         Result<Void> denied = requireBound(guideId, familyId);                    // ← 插在这行下面
-        if (denied != null) return denied;
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 ```
 
 > ⚠️ **不要**把 guard 调用放在角色校验之前。否则未绑定家庭的非规划师请求会先被 guard 拦成 403,破坏既有 `Access denied` 语义(`GuideFamilyTaskControllerTest` 中 4 个非规划师用例会由 code=500 变 403 而失败)。
@@ -1324,7 +1324,7 @@ public class GuidePlanReviewController {
             @RequestAttribute("userId") Long userId,
             @PathVariable("familyId") Long familyId) {
         Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
-        if (denied != null) return denied;
+        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;
@@ -1340,7 +1340,7 @@ public class GuidePlanReviewController {
             @PathVariable("familyId") Long familyId,
             @PathVariable("planId") Long planId) {
         Result<Void> denied = resolvePlan(role, userId, familyId, planId);
-        if (denied != null) return denied;
+        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");
@@ -1356,7 +1356,7 @@ public class GuidePlanReviewController {
             @PathVariable("familyId") Long familyId,
             @PathVariable("planId") Long planId) {
         Result<Void> denied = resolvePlan(role, userId, familyId, planId);
-        if (denied != null) return denied;
+        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));
@@ -1371,7 +1371,7 @@ public class GuidePlanReviewController {
             @PathVariable("familyId") Long familyId,
             @PathVariable("planId") Long planId) {
         Result<Void> denied = resolvePlan(role, userId, familyId, planId);
-        if (denied != null) return denied;
+        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));
@@ -1384,7 +1384,7 @@ public class GuidePlanReviewController {
      */
     private Result<Void> resolvePlan(String role, Long userId, Long familyId, Long planId) {
         Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
-        if (denied != null) return denied;
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 
         HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId);
         if (plan == null) {
@@ -1719,7 +1719,7 @@ public class GuideFamilyQueryController {
             @RequestAttribute("userId") Long userId,
             @PathVariable("familyId") Long familyId) {
         Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
-        if (denied != null) return denied;
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 
         return Result.success(guideFamilyQueryService.listFamilyMembers(familyId));
     }
@@ -2671,4 +2671,6 @@ cd /sc-data/cfc && git diff --name-only HEAD~14 HEAD | grep -E "chat\.vue|schema
 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` → 改用分层验证口径。
+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` 等价放行,既有用例断言不受影响)。