浏览代码

merge(tdd): 合入 SC-G-06 越权接口拦截实现+契约测试+执行报告(S-cfc-10)

Agnes Agent 1 周之前
父节点
当前提交
73e2f194e

+ 20 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -99,7 +99,26 @@ private static final String[] PUBLIC_PATHS = {
         "/api/family/user/switch-role",     // 角色切换(家庭侧):同上,必须登录
         "/api/family/member/list",          // 家庭成员列表:家庭私有数据,必须登录
         "/api/energy/overview",             // 五维能量概览:用户私有数据,必须登录
-        "/api/ai/health-coach/send"         // AI 健康教练:耗 AI 配额 + 家庭上下文,必须登录
+        "/api/ai/health-coach/send",        // AI 健康教练:耗 AI 配额 + 家庭上下文,必须登录
+        // SC-G-06 越权接口拦截:私有业务数据端点族统一强制登录 —— 游客无 token 一律 401,
+        // 不再匿名(userId=-1)放行到 controller 后返回 200/业务500(如 health/report/list 匿名
+        // 返回 200、energy/sandbox 返回 500「用户不存在」),避免泄露内部错误与数据。
+        // 公开白名单(登录/注册/邀请校验/商品/文章/活动/支付回调等,见 PUBLIC_PATHS +
+        // WebConfig excludePathPatterns)保持免鉴权、不被误拦。
+        "/api/family/member/",              // 家庭成员:增删改查均为家庭私有数据
+        "/api/family-platform/",            // 家庭资金平台:余额/流水/兑换,家庭私有资金数据
+        "/api/health/report/",              // 健康报告:家庭私有健康数据(上传/解析/列表/详情/指标/删除)
+        "/api/health/checkin/",             // 健康打卡:成员私有数据
+        "/api/tasks/",                      // 任务:家庭私有数据(今日/历史/审核/完成)
+        "/api/energy/",                     // 五维能量:用户私有数据(sandbox/logs/grant/score-history...)
+        "/api/wishes/",                     // 心愿:家庭私有数据
+        "/api/user/info",                   // 用户资料:个人私有
+        "/api/user/family-members",         // 家庭成员列表:家庭私有
+        "/api/user/children/list",          // 子女列表:家庭私有
+        "/api/user/action-stats",           // 用户行动统计:个人私有
+        "/api/user/address/",               // 收货地址:个人私有
+        "/api/user/data-export",            // 数据导出:隐私敏感
+        "/api/user/deactivate"              // 账号注销:隐私敏感
     };
 
     @Override

+ 7 - 4
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorLoginRequiredTest.java

@@ -64,16 +64,19 @@ public class JwtInterceptorLoginRequiredTest {
     }
 
     @Test
-    public void testNonProxyPathWithoutTokenIsAnonymous() throws Exception {
-        // 对照:非 login-required 的健康报告路径,无 token 仍为匿名放行(现有行为,防误伤)
+    public void testHealthReportListWithoutTokenIs401() throws Exception {
+        // SC-G-06:/api/health/report/* 为家庭私有健康数据,
+        // 修复前无 token 被匿名(userId=-1)放行返回 200(泄露结构),
+        // 修复后无 token 直接 401。
         MockHttpServletRequest request = new MockHttpServletRequest();
         request.setMethod("POST");
         request.setRequestURI("/api/health/report/list");
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         boolean result = jwtInterceptor.preHandle(request, response, null);
-        assertTrue(result);
-        assertEquals(-1L, request.getAttribute("userId"));
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
     }
 
     @Test

+ 303 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/ScG06JwtGuardTest.java

@@ -0,0 +1,303 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-G-06 越权接口拦截 — 三类用例(对应 tests/test-cases/guest.md TC-G-06 / TC-G-06-E / TC-G-06-P)。
+ *
+ * 正常流 TC-G-06    :游客无 token 直调受保护接口 → 401,响应为统一 Result 结构,不泄露内部堆栈。
+ * 异常流 TC-G-06-E  :缺 token / 过期&非法 token → 统一 401;公开白名单路径不被误拦,正常放行。
+ * 权限流 TC-G-06-P  :横向越权(携带本人 token 访问他人 familyId)→ 403,不泄露他人数据。
+ *
+ * 纯单元测试:MockServlet 容器 + Mockito,不依赖数据库,保证可执行性与确定性。
+ */
+public class ScG06JwtGuardTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @Mock
+    private UserService userService;
+
+    @InjectMocks
+    private FamilyAccessInterceptor familyAccessInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-06:受保护接口无 token → 401,统一 JSON,不泄露内部结构
+    // ====================================================================
+
+    @Test
+    public void testProtectedFamilyMemberListWithoutTokenIs401() throws Exception {
+        assertProtected401("/api/family/member/list");
+    }
+
+    @Test
+    public void testProtectedHealthReportListWithoutTokenIs401() throws Exception {
+        // 修复前:匿名 userId=-1 放行,返回 200 data:[](泄露结构)
+        assertProtected401("/api/health/report/list");
+    }
+
+    @Test
+    public void testProtectedTasksTodayWithoutTokenIs401() throws Exception {
+        // 修复前:匿名放行返回 200 data:[](泄露结构)
+        assertProtected401("/api/tasks/today");
+    }
+
+    @Test
+    public void testProtectedEnergySandboxWithoutTokenIs401() throws Exception {
+        // 修复前:匿名放行到 controller 返回 500「用户不存在」(泄露内部错误)
+        assertProtected401("/api/energy/sandbox");
+    }
+
+    @Test
+    public void testProtectedWishesListWithoutTokenIs401() throws Exception {
+        assertProtected401("/api/wishes/list");
+    }
+
+    @Test
+    public void testProtectedUserInfoWithoutTokenIs401() throws Exception {
+        // 修复前:匿名返回 200 data:null(泄露结构)
+        assertProtected401("/api/user/info");
+    }
+
+    @Test
+    public void testProtectedFamilyPlatformBalanceWithoutTokenIs401() throws Exception {
+        assertProtected401("/api/family-platform/balance");
+    }
+
+    @Test
+    public void testProtectedHealthCheckinListWithoutTokenIs401() throws Exception {
+        assertProtected401("/api/health/checkin/list");
+    }
+
+    /**
+     * 断言:无 token 访问受保护路径 → 拦截(false)+ HTTP 401 + 统一 Result 结构;
+     * 响应体唯一内容为 401 提示,绝不包含内部异常/堆栈(不泄露内部错误)。
+     */
+    private void assertProtected401(String path) throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI(path);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, path + " 无 token 必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(),
+                path + " 应返回 HTTP 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), path + " 响应应含统一 code=401,实际: " + body);
+        assertTrue(body.contains("请先登录"), path + " 响应应含统一 message,实际: " + body);
+        assertFalse(body.contains("Exception"), path + " 响应不应泄露内部异常: " + body);
+        assertFalse(body.contains("at com.etotem"), path + " 响应不应泄露调用堆栈: " + body);
+        assertTrue(body.contains("\"data\":null"), path + " 响应应含统一 data:null,实际: " + body);
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-06-E:缺 token / 过期&非法 token → 401;公开路径不误拦
+    // ====================================================================
+
+    @Test
+    public void testInvalidTokenOnProtectedPathIs401() throws Exception {
+        // 过期/被篡改 token:非公开受保护路径一律 401
+        when(jwtConfig.parseToken(anyString()))
+                .thenThrow(new io.jsonwebtoken.ExpiredJwtException(null, null, "token expired"));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        request.addHeader("Authorization", "Bearer expired.token.here");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        String body = response.getContentAsString();
+        assertTrue(body.contains("请先登录"));
+        assertFalse(body.contains("ExpiredJwtException"), "异常信息不得泄露到响应体: " + body);
+    }
+
+    @Test
+    public void testGarbageTokenOnProtectedPathIs401() throws Exception {
+        when(jwtConfig.parseToken(anyString())).thenThrow(new RuntimeException("jjwt parse failure"));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/tasks/today");
+        request.addHeader("Authorization", "Bearer not-a-jwt");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertFalse(response.getContentAsString().contains("jjwt"), "内部异常不得泄露: " + response.getContentAsString());
+    }
+
+    @Test
+    public void testPublicProductListWithoutTokenNotBlocked() throws Exception {
+        assertPublicPassThrough("/api/product/list");
+    }
+
+    @Test
+    public void testPublicArticlesListWithoutTokenNotBlocked() throws Exception {
+        assertPublicPassThrough("/api/articles/list");
+    }
+
+    @Test
+    public void testPublicInviteValidateWithoutTokenNotBlocked() throws Exception {
+        // 邀请落地页校验令牌:游客注册前即可调用,属公开路径,不得误拦
+        assertPublicPassThrough("/api/family/invite/validate");
+    }
+
+    @Test
+    public void testPublicInviteValidateFamilyCodeWithoutTokenNotBlocked() throws Exception {
+        assertPublicPassThrough("/api/family/invite/validate-family-code");
+    }
+
+    @Test
+    public void testPublicAuthLoginWithoutTokenNotBlocked() throws Exception {
+        assertPublicPassThrough("/api/auth/wechat-login");
+    }
+
+    @Test
+    public void testPublicPathWithInvalidTokenDowngradedAnonymous() throws Exception {
+        // 公开路径带无效/过期 token:不 401、降级匿名放行,游客可继续浏览(既有契约保持)
+        when(jwtConfig.parseToken(anyString()))
+                .thenThrow(new io.jsonwebtoken.ExpiredJwtException(null, null, "expired"));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/product/list");
+        request.addHeader("Authorization", "Bearer expired.token");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "公开路径无效 token 应降级匿名放行");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    private void assertPublicPassThrough(String path) throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI(path);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, path + " 公开路径不应被拦截");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus(), path + " 不应写入 401/403");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-06-P:横向越权 → 403;同家庭访问放行;不泄露他人数据
+    // ====================================================================
+
+    @Test
+    public void testHorizontalPrivilegeEscalationIs403() throws Exception {
+        // 本人有效家庭集 {1},请求携带他人 familyId=999 → 403
+        Long userId = 100L;
+        User user = mockUser(userId, 1L, "parent");
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(1L));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        request.setParameter("familyId", "999");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "parent");
+        request.setAttribute("roles", Collections.singletonList("parent"));
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "访问他人家庭数据应被拦截");
+        assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus(), "横向越权应返回 403");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("无权访问"), "应返回统一 403 提示,实际: " + body);
+        assertTrue(body.contains("\"data\":null"), "越权响应 data 必须为 null(不携带业务数据),实际: " + body);
+    }
+
+    @Test
+    public void testHorizontalPrivilegeEscalationDoesNotLeakBodyData() throws Exception {
+        Long userId = 200L;
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(2L));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/tasks/today");
+        request.setParameter("familyId", "777");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "child");
+        request.setAttribute("roles", Collections.singletonList("child"));
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        familyAccessInterceptor.preHandle(request, response, null);
+
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":403"), "越权响应应含统一 code=403,实际: " + body);
+        assertTrue(body.contains("\"data\":null"), "越权响应 data 必须为 null(不携带任何业务数据),实际: " + body);
+    }
+
+    @Test
+    public void testSameFamilyAccessIsAllowed() throws Exception {
+        Long userId = 300L;
+        User user = mockUser(userId, 1L, "parent");
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(1L));
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        request.setParameter("familyId", "1");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "parent");
+        request.setAttribute("roles", Collections.singletonList("parent"));
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "同家庭数据访问应放行");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    private User mockUser(Long id, Long familyId, String role) {
+        User user = new User();
+        user.setId(id);
+        user.setFamilyId(familyId);
+        user.setRole(role);
+        return user;
+    }
+}

+ 178 - 0
docs/tdd/test/automation-run-sc-g-06-2026-11-05.md

@@ -0,0 +1,178 @@
+# SC-G-06 越权接口拦截 · 三类用例执行报告(automation-run)
+
+> 场景:SC-G-06 越权接口拦截(游客) | 需求锚点:`tests/user-scenarios/应用场景总览.md` · `tests/user-scenarios/01-guest.md`(场景 G-6)· `tests/test-cases/guest.md` TC-G-06 / TC-G-06-E / TC-G-06-P
+> 设计锚点:`JwtInterceptor` 拦截 `/api/**` 除公开白名单外一律 401/403;`FamilyAccessInterceptor` 横向越权 403
+> 分支:`feat/sc-g-06-jwt-guard`(基于 origin/cfclub @ d492a3732)
+> 执行日期:2026-11-05 | 证据载体:JUnit 单测(surefire 报告)+ 本地 feature 分支后端 live HTTP 探针
+
+---
+
+## 一、场景覆盖矩阵(本场景)
+
+| 项 | 值 |
+|---|---|
+| 场景 | SC-G-06 越权接口拦截(游客 12 个 SC 之一) |
+| 正常流 | TC-G-06(P0):受保护 `/api/**` 无 token → 401/403 统一结构、不泄露内部错误 |
+| 异常流 | TC-G-06-E(P1):缺 token / 过期 token → 401;公开路径不误拦 |
+| 权限流 | TC-G-06-P(P1):横向越权(他人 familyId)→ 403,不泄露他人数据 |
+| 本场景三类用例执行数 | **19**(新写 `ScG06JwtGuardTest`)+ 存量回归 16 = **35 全部通过** |
+| 实现代码 | `cfc-backend/.../config/JwtInterceptor.java`(LOGIN_REQUIRED_PATHS 扩展) |
+
+---
+
+## 二、实现变更(支撑证据 2)
+
+**核心缺口(修复前实测,cfc.iwintrue.com 远程 deploy 最新 cfclub):**
+
+| 接口 | 修复前(匿名放行) | 修复后(无 token 401) |
+|---|---|---|
+| `POST /api/health/report/list` | `{"code":200,...,"data":[]}` (泄露结构) | `{"code":401,"message":"请先登录","data":null}` |
+| `POST /api/tasks/today` | `{"code":200,...,"data":[]}` (泄露结构) | `{"code":401,...}` |
+| `POST /api/energy/sandbox` | `{"code":500,"message":"用户不存在"}` (泄露内部错误) | `{"code":401,...}` |
+| `POST /api/energy/logs` | `{"code":500,"message":"memberId不能为空"}` | `{"code":401,...}` |
+| `POST /api/user/info` | `{"code":200,...,"data":null}` | `{"code":401,...}` |
+| `POST /api/family/member/list` | 401(此前已收敛) | 401(保持) |
+
+**根因**:`JwtInterceptor` 对「非公开、非 LOGIN_REQUIRED」路径无 token 时执行 `setAnonymousUser(userId=-1)` 放行到 controller,
+controller 返回 200/业务 500,违反「仅公开白名单免鉴权,其余 `/api/**` 一律 401/403,不泄露内部错误」。
+
+**变更内容**(`JwtInterceptor.LOGIN_REQUIRED_PATHS` 按 SEC-20261031-01 既定模式扩展私有数据端点族):
+
+```
+新增强制登录前缀/路径:
+/api/family/member/         家庭成员(家庭私有,member/list 补全为整族)
+/api/family-platform/       家庭资金平台(余额/流水/兑换)
+/api/health/report/         健康报告(上传/解析/列表/详情/指标/删除)
+/api/health/checkin/        健康打卡
+/api/tasks/                 任务(今日/历史/审核/完成)
+/api/energy/                五维能量(sandbox/logs/grant/score-history 等)
+/api/wishes/                心愿
+/api/user/info、/api/user/family-members、/api/user/children/list、
+/api/user/action-stats、/api/user/address/、/api/user/data-export、/api/user/deactivate
+```
+
+**保持免鉴权不受影响(不误拦,异常流关键断言)**:`/api/product/list`、`/api/articles/list`、
+`/api/family/invite/validate`、`/api/family/invite/validate-family-code`(邀请落地页游客校验)、
+`/api/auth/wechat-login` 及 WebConfig excludePathPatterns 支付回调等全部公开白名单。
+
+> **设计说明**:未采用「全 `/api/**` 默拒」一次性翻转——仓库 2256 个 `/api/**` 路由中含支付回调、
+> 分享归因、媒体上传、config/public 等 98+ 个合法匿名端点(逐一审计超出本场景范围),
+> 故按既有 SEC-20261031-01 模式做「私有数据端点族强制登录」的渐进收敛,
+> 由本项目三判据(正常/异常/权限流证据)闭环 SC-G-06;「全量默拒」留作后续场景推进项。
+
+---
+
+## 三、证据 1 · JUnit 单测(本地 feature 分支执行,surefire)
+
+命令:
+
+```bash
+cd cfc-backend
+mvn test -DskipTests=false -Dtest='ScG06JwtGuardTest,JwtInterceptorLoginRequiredTest,FamilyAccessInterceptorTest'
+```
+
+结果(surefire 原文):
+
+```
+Tests run:  5, Failures: 0, Errors: 0   in com.etotem.cfc.config.JwtInterceptorLoginRequiredTest
+Tests run: 11, Failures: 0, Errors: 0   in com.etotem.cfc.config.FamilyAccessInterceptorTest
+Tests run: 19, Failures: 0, Errors: 0   in com.etotem.cfc.config.ScG06JwtGuardTest
+Tests run: 35, Failures: 0, Errors: 0, Skipped: 0   BUILD SUCCESS
+```
+
+报告文件:`cfc-backend/target/surefire-reports/TEST-com.etotem.cfc.config.ScG06JwtGuardTest.xml`
+
+### 用例 → 断言映射(19 条新用例)
+
+| 类型 | 用例 | 断言 |
+|---|---|---|
+| 正常流 TC-G-06 | `testProtectedFamilyMemberListWithoutTokenIs401` | preHandle=false + HTTP 401 + 统一 Result |
+| 正常流 | `testProtectedHealthReportListWithoutTokenIs401` | 同上(修复前 200 泄露结构) |
+| 正常流 | `testProtectedTasksTodayWithoutTokenIs401` | 同上(修复前 200 泄露结构) |
+| 正常流 | `testProtectedEnergySandboxWithoutTokenIs401` | 同上(修复前 500 泄露内部错误) |
+| 正常流 | `testProtectedWishesListWithoutTokenIs401` | 同上 |
+| 正常流 | `testProtectedUserInfoWithoutTokenIs401` | 同上(修复前 200 null) |
+| 正常流 | `testProtectedFamilyPlatformBalanceWithoutTokenIs401` | 同上 |
+| 正常流 | `testProtectedHealthCheckinListWithoutTokenIs401` | 同上 |
+| —(公共断言 helper) | `assertProtected401` | 响应仅含 `{"code":401,"message":"请先登录","data":null}`;不含 `Exception`/堆栈 |
+| 异常流 TC-G-06-E | `testInvalidTokenOnProtectedPathIs401` | 过期 token → 401,异常类名不泄露 |
+| 异常流 | `testGarbageTokenOnProtectedPathIs401` | 非法 token → 401,内部 msg 不泄露 |
+| 异常流 | `testPublicProductListWithoutTokenNotBlocked` | 公开路径放行、不写 401 |
+| 异常流 | `testPublicArticlesListWithoutTokenNotBlocked` | 同上 |
+| 异常流 | `testPublicInviteValidateWithoutTokenNotBlocked` | 邀请落地页校验放行(关键防误拦) |
+| 异常流 | `testPublicInviteValidateFamilyCodeWithoutTokenNotBlocked` | 同上 |
+| 异常流 | `testPublicAuthLoginWithoutTokenNotBlocked` | 登录接口放行 |
+| 异常流 | `testPublicPathWithInvalidTokenDowngradedAnonymous` | 公开路径+无效 token 降级匿名放行(既有契约保持) |
+| 权限流 TC-G-06-P | `testHorizontalPrivilegeEscalationIs403` | 本人有效家庭 {1},请求 familyId=999 → 403 + data:null |
+| 权限流 | `testHorizontalPrivilegeEscalationDoesNotLeakBodyData` | 越权响应统一 code=403,data 为 null 不携带数据 |
+| 权限流 | `testSameFamilyAccessIsAllowed` | 本人家庭访问放行(对照) |
+
+> 存量回归:`JwtInterceptorLoginRequiredTest` 中原 `testNonProxyPathWithoutTokenIsAnonymous`
+> (断言 `/api/health/report/list` 匿名放行——即本次修复的泄露行为)已更新为
+> `testHealthReportListWithoutTokenIs401`,与修复后契约一致。
+
+---
+
+## 四、证据 2 · live HTTP 探针(feature 分支后端本机启动,端口 9082)
+
+后端由 feature 分支代码 `mvn spring-boot:run`(JDK 11,测试库 192.168.16.251/zxyj,`wechat.test-mode=true`)启动,实测输出:
+
+### 4.1 正常流 TC-G-06:受保护接口无 token → 统一 401
+
+```
+POST /api/family/member/list      -> {"code":401,"message":"请先登录","data":null}
+POST /api/health/report/list      -> {"code":401,"message":"请先登录","data":null}
+POST /api/tasks/today             -> {"code":401,"message":"请先登录","data":null}
+POST /api/energy/sandbox          -> {"code":401,"message":"请先登录","data":null}
+POST /api/wishes/list             -> {"code":401,"message":"请先登录","data":null}
+POST /api/user/info               -> {"code":401,"message":"请先登录","data":null}
+POST /api/family-platform/balance -> {"code":401,"message":"请先登录","data":null}
+POST /api/health/checkin/list     -> {"code":401,"message":"请先登录","data":null}
+```
+8/8 全部 401,响应体唯一内容为统一 Result,无内部错误/堆栈。
+
+### 4.2 异常流 TC-G-06-E:缺/过期 token 统一 401;公开路径不误拦
+
+```
+# 无效/过期 token → 受保护路径统一 401(异常类名不泄露)
+POST /api/health/report/list + invalid Bearer -> {"code":401,"message":"请先登录","data":null}
+POST /api/tasks/today        + invalid Bearer -> {"code":401,"message":"请先登录","data":null}
+
+# 公开白名单无 token → 正常放行(HTTP 200,不误拦)
+POST /api/product/list                    -> 200
+POST /api/articles/list                   -> 200
+POST /api/family/invite/validate          -> 200   # 邀请落地页游客校验
+POST /api/auth/wechat-login               -> 200
+
+# 公开路径 + 无效 token → 降级匿名放行(既有契约保持)
+POST /api/product/list + invalid Bearer   -> 200
+```
+
+### 4.3 权限流 TC-G-06-P:横向越权 → 403;本人家庭对照放行
+
+(`/api/auth/direct-register` 注册 parent 用户,持真实 token)
+
+```
+# 本人 token + 他人 familyId=999999999 → 403,不泄露数据
+POST /api/health/report/list {"familyId":999999999} -> {"code":403,"message":"无权访问该家庭数据","data":null}
+POST /api/tasks/today        {"familyId":999999999} -> {"code":403,"message":"无权访问该家庭数据","data":null}
+
+# 对照:本人 token 不携带他人 familyId → 正常执行业务(HTTP 200,非 403)
+POST /api/health/report/list {} -> 200
+POST /api/tasks/today        {} -> 200
+```
+
+全部探针 13 项,与预期契约一致。
+
+---
+
+## 五、结论
+
+| 验收判据 | 结果 |
+|---|---|
+| 1. 三类用例(正常/异常/权限)有实际执行证据 | ✅ JUnit 35 通过(含新写 19)+ live HTTP 探针 13 项全部符合 |
+| 2. 实现+用例提交并 push feature 分支,可 merge-base 核验 | ✅ 见提交 sha(本文档同批次 commit) |
+| 3. 工作目录干净 | ✅ `git status --porcelain` 为空 |
+
+> 遗留(非本场景阻塞,记录在案):全 `/api/**` 默认拒绝 + 98 个合法匿名端点审计,建议作为
+> SC-G-06 深化或独立安全场景继续推进;本场景按「私有数据端点族强制登录」闭环。