Ver Fonte

feat(guide): 新增客户家庭方案审核 4 端点,含 planId IDOR 反查防护

iwt há 2 dias atrás
pai
commit
41c6d32612

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java

@@ -0,0 +1,109 @@
+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 denied;
+
+        HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId);
+        if (plan == null) {
+            return Result.error(403, "无权访问该方案");
+        }
+        // 兜底重复校验:getPlanForFamily 已按 familyId 过滤,故此处 plan.getFamilyId() 必然等于
+        // 上方已校验的 familyId,本次调用实际不会拒绝任何请求。保留仅为纵深防御,勿依赖它拦截路径不一致。
+        Result<Void> recheck = guideFamilyAccessGuard.checkFamilyAccess(role, userId, plan.getFamilyId());
+        if (recheck != null) return recheck;
+
+        return null;
+    }
+}

+ 115 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java

@@ -0,0 +1,115 @@
+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);
+    }
+}