Преглед изворни кода

feat(guide): 新增 GuideFamilyAccessGuard 统一家庭访问授权判定

iwt пре 2 дана
родитељ
комит
ac881b6173

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java

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

+ 87 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java

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

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

@@ -37,11 +37,19 @@ export PATH=/bwydata/maven/bin:$PATH
 |---|---|---|
 | 编译 | `mvn clean compile -DskipTests` | 主代码必须 0 错误 |
 | 测试编译 | `mvn test-compile` | 必须 exit 0 |
-| 新增单测 | `mvn test -Dtest=<新增用例>` | 纯 Mockito,不碰 DB,可稳定通过 |
+| 新增单测 | `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。
@@ -370,7 +378,7 @@ class GuideFamilyAccessGuardTest {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "cannot find symbol|Tests run:|BUILD" | head -5
+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`。
@@ -437,7 +445,7 @@ public class GuideFamilyAccessGuard {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+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`。
@@ -557,7 +565,7 @@ class GuideFamilyServiceBindTest {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "重绑时应复活status|Tests run:|expected" | head -6
+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"`。
@@ -596,7 +604,7 @@ cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyServiceBindTest 2>&1 |
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+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`。
@@ -868,7 +876,7 @@ class HealthPlanReviewGuardTest {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "已发布方案不可再次驳回|cannot find symbol|Tests run:" | head -5
+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。
@@ -921,7 +929,7 @@ cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 |
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+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`。
@@ -1024,7 +1032,7 @@ public class PlanApproveResultDTO {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "cannot find symbol|incompatible types|Tests run:" | head -5
+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`。
@@ -1117,7 +1125,7 @@ import 区新增 `com.etotem.cfc.dto.PlanApproveResultDTO`。
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+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`。
@@ -1271,7 +1279,7 @@ class GuidePlanReviewControllerAuthTest {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+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`。
@@ -1395,7 +1403,7 @@ public class GuidePlanReviewController {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+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`。
@@ -1648,7 +1656,7 @@ class GuideFamilyQueryServiceTest {
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+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`。
@@ -1657,7 +1665,7 @@ cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyQueryServiceTest 2>&1
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -6
+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`。
@@ -1772,7 +1780,7 @@ cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -c "ERROR.*\.java
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:|BUILD" | tail -8
+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`。
@@ -2579,7 +2587,7 @@ cd /sc-data/cfc/cfc-backend && mvn clean test-compile -q 2>&1 | grep -c "ERROR.*
 
 ```bash
 export PATH=/bwydata/maven/bin:$PATH
-cd /sc-data/cfc/cfc-backend && mvn test -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:.*Failures|BUILD" | tail -8
+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`。