Kaynağa Gözat

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

zxyj-langgraph 1 gün önce
ebeveyn
işleme
2d4e4208b3

+ 357 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyChildContextSwitchContractTest.java

@@ -0,0 +1,357 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.family.FamilyMembersController;
+import com.etotem.cfc.dto.SwitchMemberVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyMemberService;
+import com.etotem.cfc.service.SystemConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.security.access.AccessDeniedException;
+
+import java.lang.reflect.Field;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code family.childContext.switch}(SC-P-73 多孩子切换孩子上下文,TDD-RED 阶段)。
+ *
+ * <p><b>溯源·需求</b>:{@code tests/user-scenarios/应用场景总览.md} SC-P-73(业务域 J)——
+ * 多孩子家庭切换:选孩子 → 任务/报告/方案上下文切换;边界:仅本家庭孩子可选;当前孩子态隔离。
+ * 派生用例 {@code tests/test-cases/parent.md} TC-P-73(正常)/ TC-P-73-E(异常)/ TC-P-73-P(权限)。</p>
+ *
+ * <p><b>溯源·设计</b>:{@code docs/superpowers/api/API_REFERENCE.md} §4.2 / §4.61.1 / §4.63.1——
+ * 载体 {@code POST /api/family/member/switch}(FamilyMembersController.switchToMember →
+ * FamilyMemberService.switchToMember)→ 响应 {@code SwitchMemberVO{memberId, source,
+ * effectiveRole, nickname, isSwitchable}}。
+ * 与能力键 {@code family.parent.switchRole}(S-cfc-11,P-03 家长↔孩子角色切换)区分:
+ * 本键 = P-73 <b>孩子间上下文切换</b>(多孩子家庭中切换当前孩子态)。</p>
+ *
+ * <p><b>实现现状与 RED 锚点</b>:端点 {@code /switch} 已存在并落地了「家长 → 不可登录孩子」的
+ * 单次视角切换(P-03 语义),但 P-73 的<b>孩子上下文分类/隔离</b>默认值未落地——
+ * {@code computeEffectiveRole} 在被切换的不可登录孩子(managed child,{@code userId=null})
+ * <b>既无 birthday 又无 generation</b> 时默认返回 {@code "parent"}(FamilyMemberService.java:1392),
+ * 导致「选孩子」后返回的 {@code effectiveRole="parent"}、前端据此把当前孩子上下文误判为家长上下文,
+ * 违反 SC-P-73「选孩子 → 任务/报告/方案按孩子上下文隔离」契约(RED 用例)。
+ * 其余(跨家庭拒绝 / 自切拒绝 / 可登录成员拒绝 / 孩子端切家长拒绝 / 端点 401 / 缺 memberId)当前已落地,
+ * 作为 GREEN 锁定用例,防 GREEN 阶段误伤已落地行为。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring 上下文 / 无 DB / 无微信 / 无外部服务。
+ * 风格对齐同批 RED 契约测试(FamilyRemoveMemberContractTest / FamilyJoinValidationRulesContractTest)——
+ * 结果契约断言 + RED/GREEN 混编锁定。</p>
+ */
+@DisplayName("family.childContext.switch 契约(FamilyMembersController /switch → FamilyMemberService.switchToMember)")
+class FamilyChildContextSwitchContractTest {
+
+    /** 被测试家庭 */
+    private static final Long FAMILY_ID = 100L;
+    /** 当前登录家长 userId */
+    private static final Long PARENT_USER_ID = 1L;
+    /** 不能登录孩子成员(managed child,family_members.userId = null)A */
+    private static final Long CHILD_A_MEMBER_ID = 11L;
+    /** 不能登录孩子成员(managed child,family_members.userId = null)B —— 切换目标 */
+    private static final Long CHILD_B_MEMBER_ID = 12L;
+    /** 可登录成员关联 userId(有 openid) */
+    private static final Long LOGIN_MEMBER_USER_ID = 200L;
+    /** 非本家庭 memberId */
+    private static final Long OTHER_FAMILY_MEMBER_ID = 999L;
+    /** 孩子年龄阈值(测试桩) */
+    private static final int CHILD_AGE_THRESHOLD = 18;
+
+    // ---------------- Service 层被测对象及依赖 ----------------
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private SystemConfigService systemConfigService;
+
+    @InjectMocks
+    private FamilyMemberService familyMemberService;
+
+    // ---------------- Controller 层被测对象 ----------------
+    private FamilyMembersController controller;
+    /** Controller 层注入的 mock 服务(verify 用) */
+    private FamilyMemberService controllerService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        when(systemConfigService.getChildAgeThreshold()).thenReturn(CHILD_AGE_THRESHOLD);
+
+        // Controller 层:反射注入 mock 的 FamilyMemberService(贴合既有 unit 风格)
+        controller = new FamilyMembersController();
+        controllerService = mock(FamilyMemberService.class);
+        setField(controller, "familyMemberService", controllerService);
+    }
+
+    // ====================================================================
+    // Controller 契约层(POST /api/family/member/switch)
+    // ====================================================================
+
+    @Test
+    @DisplayName("Controller 正常流:memberId 传递到服务层,返回 SwitchMemberVO")
+    void controller_正常切换_透传服务层VO() {
+        SwitchMemberVO vo = childContextVo(CHILD_B_MEMBER_ID, "child");
+        when(controllerService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID)).thenReturn(vo);
+
+        Result<SwitchMemberVO> r = controller.switchToMember(body(CHILD_B_MEMBER_ID), PARENT_USER_ID);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals(CHILD_B_MEMBER_ID, r.getData().getMemberId());
+        verify(controllerService).switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+    }
+
+    @Test
+    @DisplayName("Controller 异常流:未登录(userId=null)→ 401,不触达服务层")
+    void controller_未登录_userId为null_401() {
+        Result<SwitchMemberVO> r = controller.switchToMember(body(CHILD_B_MEMBER_ID), null);
+        assertEquals(401, r.getCode());
+        assertEquals("请先登录", r.getMessage());
+        verify(controllerService, never()).switchToMember(any(), any());
+    }
+
+    @Test
+    @DisplayName("Controller 异常流:缺 memberId → 400「缺少成员ID」,不触达服务层")
+    void controller_缺少memberId_400() {
+        Result<SwitchMemberVO> r = controller.switchToMember(Collections.emptyMap(), PARENT_USER_ID);
+        assertEquals(400, r.getCode());
+        assertEquals("缺少成员ID", r.getMessage());
+        verify(controllerService, never()).switchToMember(any(), any());
+    }
+
+    // ====================================================================
+    // Service 正常流(SC-P-73 / TC-P-73:选孩子 → 孩子上下文切换)
+    // ====================================================================
+
+    @Test
+    @DisplayName("正常流-1:多孩子家庭,家长切换到本家庭不可登录孩子B → 返回孩子B上下文VO")
+    void service_多孩子切换到孩子B_返回孩子B上下文VO() {
+        stubParent(PARENT_USER_ID);
+        FamilyMember childB = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(10), "小宝");
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(childB);
+
+        SwitchMemberVO vo = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+
+        assertNotNull(vo);
+        assertEquals(CHILD_B_MEMBER_ID, vo.getMemberId());
+        assertEquals("family_member", vo.getSource());
+        assertEquals("child", vo.getEffectiveRole());
+        assertEquals("小宝", vo.getNickname());
+        assertTrue(Boolean.TRUE.equals(vo.getIsSwitchable()));
+    }
+
+    @Test
+    @DisplayName("正常流-2:目标孩子有 birthday 且未成年 → effectiveRole=child")
+    void service_目标孩子有生日未成年_effectiveRole为child() {
+        stubParent(PARENT_USER_ID);
+        FamilyMember childB = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(10), null);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(childB);
+
+        SwitchMemberVO vo = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+
+        assertEquals("child", vo.getEffectiveRole());
+    }
+
+    @Test
+    @DisplayName("正常流-3:目标孩子无 birthday 但 generation 为负 → effectiveRole=child")
+    void service_目标孩子无生日generation为负_effectiveRole为child() {
+        stubParent(PARENT_USER_ID);
+        FamilyMember childB = managedChild(CHILD_B_MEMBER_ID, null, null);
+        childB.setGeneration(-1);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(childB);
+
+        SwitchMemberVO vo = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+
+        assertEquals("child", vo.getEffectiveRole());
+    }
+
+    /**
+     * <b>RED 用例</b>:被切换的不可登录孩子(managed child,{@code userId=null})既无 birthday
+     * 又无 generation 时,切换返回的 {@code effectiveRole} 必须仍归为孩子上下文({@code child}),
+     * 以保证 SC-P-73「选孩子 → 任务/报告/方案按孩子上下文隔离」。
+     *
+     * <p><b>当前实现</b>:{@code computeEffectiveRole} 无 birthday、无 generation 时默认返回
+     * {@code "parent"}(FamilyMemberService.java:1392)——把 managed child 误判为家长上下文,
+     * 前端据此把当前孩子态置为家长,孩子上下文隔离失效。<b>预期失败(红)</b>;GREEN 阶段实现
+     * 「userId=null 的不可登录成员 = 孩子上下文」的默认分类后转绿。</p>
+     */
+    @Test
+    @DisplayName("RED-正常流-4:managed child 无生日无 generation,切换仍应归为孩子上下文(child)")
+    void service_managedChild无生日无generation_仍应归为孩子上下文() {
+        stubParent(PARENT_USER_ID);
+        // userId=null 的不可登录孩子,未填 birthday / generation(多孩子家庭常见数据边界)
+        FamilyMember childB = managedChild(CHILD_B_MEMBER_ID, null, null);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(childB);
+
+        SwitchMemberVO vo = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+
+        assertNotNull(vo, "切换应成功返回孩子上下文VO");
+        assertEquals("child", vo.getEffectiveRole(),
+                "[family.childContext.switch] managed child(userId=null,无 birthday 无 generation)"
+                        + "切换后 effectiveRole 应为 child(孩子上下文),实际=" + vo.getEffectiveRole()
+                        + " —— SC-P-73 当前孩子态隔离契约未落地:computeEffectiveRole 无年龄数据时默认 parent,"
+                        + "未实现孩子上下文默认分类");
+    }
+
+    // ====================================================================
+    // Service 异常流(TC-P-73-E:非本家庭不可选;切换保持各孩子上下文隔离不串)
+    // ====================================================================
+
+    @Test
+    @DisplayName("异常流-1:非本家庭 memberId → AccessDenied(不泄露他人家庭信息)")
+    void service_非本家庭memberId_拒绝() {
+        stubParent(PARENT_USER_ID);
+        // 跨家庭:其他家庭的成员
+        FamilyMember otherFamilyChild = managedChild(OTHER_FAMILY_MEMBER_ID, ageYearsAgo(8), null);
+        otherFamilyChild.setFamilyId(888L);
+        when(familyMemberMapper.selectById(OTHER_FAMILY_MEMBER_ID)).thenReturn(otherFamilyChild);
+
+        AccessDeniedException ex = assertThrows(AccessDeniedException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, OTHER_FAMILY_MEMBER_ID));
+        assertEquals("成员不存在或不属于您的家庭", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("异常流-2:切换到自己 → IllegalArgumentException「不能切换到自己」")
+    void service_切换到自己_拒绝() {
+        stubParent(PARENT_USER_ID);
+        // 家庭主账号成员(userId = 本人),走 family_members 段自切判定
+        FamilyMember selfMember = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(35), null);
+        selfMember.setUserId(PARENT_USER_ID);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(selfMember);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID));
+        assertEquals("不能切换到自己", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("异常流-3:目标为可登录成员(有 openid)→ 拒绝「不能切换为TA的身份」")
+    void service_目标是可登录成员_拒绝() {
+        stubParent(PARENT_USER_ID);
+        // 可登录成员:userId 关联 User 有 openid
+        FamilyMember loginMember = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(12), null);
+        loginMember.setUserId(LOGIN_MEMBER_USER_ID);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(loginMember);
+        User memberUser = new User();
+        memberUser.setId(LOGIN_MEMBER_USER_ID);
+        memberUser.setOpenid("wx_openid_" + LOGIN_MEMBER_USER_ID);
+        when(userMapper.selectById(LOGIN_MEMBER_USER_ID)).thenReturn(memberUser);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID));
+        assertEquals("该成员可通过微信登录,不能切换为TA的身份", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("异常流-4:用户未加入家庭 → IllegalArgumentException「用户未加入家庭」")
+    void service_用户未加入家庭_拒绝() {
+        User noFamily = new User();
+        noFamily.setId(PARENT_USER_ID);
+        noFamily.setFamilyId(null);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(noFamily);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID));
+        assertEquals("用户未加入家庭", ex.getMessage());
+    }
+
+    // ====================================================================
+    // Service 权限流(TC-P-73-P:双角色铁律——孩子端不能切回家长)
+    // ====================================================================
+
+    @Test
+    @DisplayName("权限流:直接登录孩子端(role=child)切换到家长 effectiveRole → AccessDenied")
+    void service_孩子端切换到家长视角_拒绝() {
+        // 操作者 = 直接登录的孩子(role=child,有 familyId)
+        User childOperator = new User();
+        childOperator.setId(PARENT_USER_ID);
+        childOperator.setFamilyId(FAMILY_ID);
+        childOperator.setRole("child");
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(childOperator);
+        // 目标成员 effectiveRole=parent(成年生日),触发「孩子端不能切回家长」铁律
+        FamilyMember adultMember = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(30), null);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(adultMember);
+
+        AccessDeniedException ex = assertThrows(AccessDeniedException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID));
+        assertEquals("家庭成员账号不能切换到本人视角", ex.getMessage());
+    }
+
+    // ====================================================================
+    // 辅助构造
+    // ====================================================================
+
+    /** 构造家长 User(role=parent,已加入 FAMILY_ID 家庭) */
+    private void stubParent(Long userId) {
+        User parent = new User();
+        parent.setId(userId);
+        parent.setFamilyId(FAMILY_ID);
+        parent.setRole("parent");
+        when(userMapper.selectById(userId)).thenReturn(parent);
+    }
+
+    /** 构造不可登录孩子成员(managed child,userId=null,归属 FAMILY_ID) */
+    private FamilyMember managedChild(Long memberId, Date birthday, String nickname) {
+        FamilyMember fm = new FamilyMember();
+        fm.setId(memberId);
+        fm.setFamilyId(FAMILY_ID);
+        fm.setUserId(null);
+        fm.setBirthday(birthday);
+        fm.setNickname(nickname);
+        return fm;
+    }
+
+    /** 构造孩子上下文 SwitchMemberVO(契约响应形态) */
+    private SwitchMemberVO childContextVo(Long memberId, String effectiveRole) {
+        SwitchMemberVO vo = new SwitchMemberVO();
+        vo.setMemberId(memberId);
+        vo.setSource("family_member");
+        vo.setEffectiveRole(effectiveRole);
+        vo.setNickname("小宝");
+        vo.setIsSwitchable(true);
+        return vo;
+    }
+
+    private Map<String, Object> body(Long memberId) {
+        Map<String, Object> b = new HashMap<>();
+        b.put("memberId", memberId);
+        return b;
+    }
+
+    /** 距今 N 年的生日 */
+    private Date ageYearsAgo(int years) {
+        Calendar c = Calendar.getInstance();
+        c.add(Calendar.YEAR, -years);
+        return c.getTime();
+    }
+
+    private static void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}

+ 302 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GrowthLeaderboardViewContractTest.java

@@ -0,0 +1,302 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.GameRecordController;
+import com.etotem.cfc.controller.stats.StatsController;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GameRecordMapper;
+import com.etotem.cfc.mapper.MemberBadgeMapper;
+import com.etotem.cfc.service.BadgeService;
+import com.etotem.cfc.service.GameRecordService;
+import org.apache.ibatis.annotations.Select;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code growth.leaderboard.view}(<b>TDD-RED / 契约登记批次</b>)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-86(成长打卡榜/家庭排行:
+ *       看榜 → 排名/进度;<b>边界:数据仅本家庭可见;排名实时性</b>);</li>
+ *   <li>用例:{@code tests/test-cases/parent.md} TC-P-86(正常流:看榜→排名/进度)、
+ *       TC-P-86-E(异常流:看他人家庭排行榜不可见 / 排名实时性滞后)、
+ *       TC-P-86-P(权限流:非归属家庭数据统一拦截);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.3
+ *       {@code POST /api/game/leaderboard}(游戏排行榜)、§4.61.4
+ *       {@code POST /api/badges/ranking/family}(家庭勋章排行)、§4.60.2
+ *       {@code POST /api/stats/points/ranking}(积分排行);</li>
+ *   <li>编排登记:{@code docs/tdd/orchestration/sc-key-map.md} SC-P-86 →
+ *       growth.leaderboard.view → S-cfc-302。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:三个排行端点的<b>家庭隔离</b>与<b>进度字段</b>契约。
+ * 仅用 Mockito + 反射(无 Spring / 无 DB / 无 Redis / 无真实外部服务),断言不依赖真实数据源。
+ * 游戏排行的 Redis 路径用 {@code opsForZSet()} 未 stub(返回 null → NPE 被 try-catch 吞掉)
+ * 强制走 DB 回退分支,从而以 {@code gameRecordMapper} mock 精确驱动。</p>
+ *
+ * <p><b>基线状态(实测,TDD-RED 阶段实现未落地)</b>:</p>
+ * <ul>
+ *   <li><b>游戏排行榜无家庭隔离</b>:{@code GameRecordService.getLeaderboard(String, Integer)}
+ *       无 {@code familyId} 参数;{@code GameRecordMapper.selectLeaderboard} SQL 仅按
+ *       {@code game_code} 过滤(无家庭约束);{@code GameRecordController} 未消费
+ *       {@code @RequestAttribute familyId};Redis key {@code game:leaderboard:{gameCode}}
+ *       全局共享不区分家庭 —— 违反 SC-P-86「数据仅本家庭可见」;</li>
+ *   <li><b>排行项无进度字段</b>:三个排行端点返回项均只含
+ *       {@code rank/memberId/childName/points}(或 {@code child_id/total}),
+ *       缺 SC-P-86「看榜 → 排名/进度」中的<b>进度</b>维度。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:上述缺口在 src/main 实现补齐前应失败(红);
+ * GREEN 阶段按契约补齐(游戏排行榜家庭隔离 + 排行项进度字段)后自动转绿。
+ * 若实测意外全绿,交编排侧核实能力键状态(转测试登记),不私改 src/main 实现。</p>
+ */
+class GrowthLeaderboardViewContractTest {
+
+    /** 测试家庭:family_members.family_id(会话家庭维度) */
+    private static final Long FAMILY_ID = 9L;
+
+    // ---------- 游戏排行榜(/api/game/leaderboard) ----------
+    @Mock
+    private GameRecordMapper gameRecordMapper;
+
+    /** 未 stub:opsForZSet() 返回 null → try-catch 吞掉 NPE → 强制走 DB 回退分支 */
+    @Mock
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @InjectMocks
+    private GameRecordService gameRecordService;
+
+    // ---------- 积分排行(/api/stats/points/ranking) ----------
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private StatsController statsController;
+
+    // ---------- 家庭勋章排行(/api/badges/ranking/family) ----------
+    @Mock
+    private MemberBadgeMapper memberBadgeMapper;
+
+    @InjectMocks
+    private BadgeService badgeService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ================================================================
+    // 正常流:游戏排行榜家庭隔离(SC-P-86「数据仅本家庭可见」)
+    // ================================================================
+
+    @Test
+    @DisplayName("正常流-1:GameRecordService.getLeaderboard 应接受 familyId 参数(家庭隔离)")
+    void 游戏排行_service应接受familyId参数() {
+        Method m;
+        try {
+            m = GameRecordService.class.getDeclaredMethod("getLeaderboard", String.class, Integer.class);
+        } catch (NoSuchMethodException e) {
+            throw new AssertionError(
+                    "[growth.leaderboard.view] GameRecordService.getLeaderboard(String, Integer) 未按契约签名存在。", e);
+        }
+        boolean hasFamilyId = false;
+        for (Class<?> p : m.getParameterTypes()) {
+            if (p == Long.class || p == long.class) {
+                hasFamilyId = true;
+                break;
+            }
+        }
+        assertTrue(hasFamilyId,
+                "[growth.leaderboard.view] GameRecordService.getLeaderboard 缺 familyId 参数:游戏排行榜"
+                        + "按全局返回(Redis key game:leaderboard:{gameCode} 不区分家庭 + SQL 无家庭过滤),"
+                        + "违反 SC-P-86『数据仅本家庭可见』。GREEN 应新增 familyId 维度(会话家庭)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:GameRecordMapper.selectLeaderboard 的 SQL 应含 family_id 过滤")
+    void 游戏排行_mapperSQL应含family_id过滤() throws Exception {
+        Method m = GameRecordMapper.class.getDeclaredMethod("selectLeaderboard", String.class, int.class);
+        Select select = m.getAnnotation(Select.class);
+        assertNotNull(select,
+                "[growth.leaderboard.view] GameRecordMapper.selectLeaderboard 应以 @Select 声明(现状保留)。");
+        String sql = String.join(" ", select.value());
+        assertTrue(sql.contains("family_id"),
+                "[growth.leaderboard.view] selectLeaderboard SQL 未按家庭过滤(应加 fm.family_id = #{familyId}"
+                        + " 之类约束),返回跨家庭排行榜,违反 SC-P-86『数据仅本家庭可见』。实际 SQL: " + sql);
+    }
+
+    @Test
+    @DisplayName("正常流-3:GameRecordController.getLeaderboard 应消费 @RequestAttribute(\"familyId\")")
+    void 游戏排行_controller应读取会话familyId() throws Exception {
+        Method m = GameRecordController.class.getDeclaredMethod("getLeaderboard", Map.class);
+        boolean hasFamilyAttr = false;
+        for (Annotation[] anns : m.getParameterAnnotations()) {
+            for (Annotation a : anns) {
+                if (a instanceof RequestAttribute && "familyId".equals(((RequestAttribute) a).value())) {
+                    hasFamilyAttr = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(hasFamilyAttr,
+                "[growth.leaderboard.view] GameRecordController.getLeaderboard 未消费 @RequestAttribute(\"familyId\"):"
+                        + "排行无法按会话家庭过滤,违反 SC-P-86『数据仅本家庭可见』"
+                        + "(badges/points 排行已按会话 familyId 隔离,game 排行需对齐)。");
+    }
+
+    // ================================================================
+    // 正常流:排行项应含进度字段(SC-P-86「看榜 → 排名/进度」)
+    // ================================================================
+
+    @Test
+    @DisplayName("正常流-4:游戏排行榜项应含进度字段(progress/totalMembers)")
+    void 游戏排行_项应含进度字段() {
+        Map<String, Object> row = new HashMap<>();
+        row.put("memberId", 1L);
+        row.put("score", 100);
+        row.put("rank", 1);
+        row.put("child_name", "小明");
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(row);
+        when(gameRecordMapper.selectLeaderboard(isNull(), anyInt())).thenReturn(rows);
+
+        List<Map<String, Object>> board = gameRecordService.getLeaderboard(null, 10);
+
+        assertNotNull(board);
+        assertEquals(1, board.size());
+        assertCarriesProgress(board.get(0), "游戏排行榜");
+    }
+
+    @Test
+    @DisplayName("正常流-5:积分排行项应含进度字段(progress/totalMembers)")
+    void 积分排行_项应含进度字段() {
+        List<FamilyMember> members = new ArrayList<>();
+        members.add(familyMember(1L, "小明", 120));
+        members.add(familyMember(2L, "小红", 80));
+        when(familyMemberMapper.selectList(any())).thenReturn(members);
+
+        Result<List<Map<String, Object>>> res = statsController.pointsRanking(new HashMap<>(), FAMILY_ID);
+
+        List<Map<String, Object>> data = res.getData();
+        assertNotNull(data);
+        assertEquals(2, data.size());
+        for (Map<String, Object> item : data) {
+            assertCarriesProgress(item, "积分排行");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-6:家庭勋章排行项应含进度字段(progress/totalMembers)")
+    void 家庭勋章排行_项应含进度字段() {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(badgeRankRow(1L, 5L));
+        rows.add(badgeRankRow(2L, 3L));
+        when(memberBadgeMapper.selectFamilyRanking(FAMILY_ID)).thenReturn(rows);
+
+        List<Map<String, Object>> data = badgeService.getFamilyRanking(FAMILY_ID);
+
+        assertNotNull(data);
+        assertEquals(2, data.size());
+        for (Map<String, Object> item : data) {
+            assertCarriesProgress(item, "家庭勋章排行");
+        }
+    }
+
+    // ================================================================
+    // 异常流:跨家庭数据不得泄露(TC-P-86-E「看他人家庭排行榜不可见」)
+    // ================================================================
+
+    @Test
+    @DisplayName("异常流-1:跨家庭数据行注入后应被过滤到本家庭(不得原样返回)")
+    void 游戏排行_跨家庭数据行应被过滤() {
+        Map<String, Object> own = new HashMap<>();
+        own.put("memberId", 1L);
+        own.put("familyId", FAMILY_ID);
+        own.put("score", 90);
+        Map<String, Object> other = new HashMap<>();
+        other.put("memberId", 2L);
+        other.put("familyId", 77L);
+        other.put("score", 95);
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(own);
+        rows.add(other);
+        when(gameRecordMapper.selectLeaderboard(isNull(), anyInt())).thenReturn(rows);
+
+        List<Map<String, Object>> board = gameRecordService.getLeaderboard(null, 10);
+
+        assertNotNull(board);
+        assertEquals(2, board.size(),
+                "[growth.leaderboard.view] 上游返回的跨家庭行应被服务端过滤(契约 GREEN 实现后此处应仅剩本家庭行)");
+        for (Map<String, Object> item : board) {
+            assertEquals(FAMILY_ID, ((Number) item.get("familyId")).longValue(),
+                    "[growth.leaderboard.view] 本家庭排行榜不得返回其他家庭(familyId=77)成员数据,"
+                            + "违反 SC-P-86『数据仅本家庭可见』(TC-P-86-E)。");
+        }
+    }
+
+    // ================================================================
+    // 权限流(说明性):SC-P-86-P 越权拦截
+    // ================================================================
+
+    @Test
+    @DisplayName("权限流(说明性):家庭勋章/积分排行已按会话 familyId 隔离;游戏排行隔离缺失由红断言覆盖")
+    void 权限流_说明性() {
+        // SC-P-86-P:非归属家庭数据统一 403/401、不泄露他人数据。
+        // 现状核实:/api/badges/ranking/family 与 /api/stats/points/ranking 的 Controller
+        // 均已 @RequestAttribute("familyId") 且未入家庭返回 noFamily(越权=无家庭)已拦截;
+        // /api/game/leaderboard 的家庭隔离缺失由正常流-1/2/3、异常流-1 红断言覆盖。
+        assertTrue(true,
+                "权限流不适用单独红断言:家庭隔离契约已由正常/异常流断言覆盖;"
+                        + "badges/points 排行会话 familyId 拦截已实现。");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    /** 契约断言:排行项应携带进度信息(字段名宽松:progress / totalMembers / completionRate) */
+    private void assertCarriesProgress(Map<String, Object> item, String boardName) {
+        assertTrue(item.containsKey("progress") || item.containsKey("totalMembers")
+                        || item.containsKey("completionRate"),
+                "[growth.leaderboard.view] " + boardName + " 项缺进度维度:SC-P-86 流程为「看榜 → 排名/进度」,"
+                        + "排行项应携带进度信息(如 排名/总成员数、完成率)。实际 keys=" + item.keySet());
+    }
+
+    private FamilyMember familyMember(Long id, String nickname, Integer totalPoints) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(FAMILY_ID);
+        m.setNickname(nickname);
+        m.setTotalPoints(totalPoints);
+        return m;
+    }
+
+    private Map<String, Object> badgeRankRow(Long memberId, Long total) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("child_id", memberId);
+        row.put("total", total);
+        return row;
+    }
+}