Procházet zdrojové kódy

Merge remote-tracking branch 'origin/cfclub' into task/S-cfc-299

Sisyphus před 22 hodinami
rodič
revize
f008ea8330

+ 520 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/AuthSessionMutexContractTest.java

@@ -0,0 +1,520 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.dto.LoginResultDTO;
+import io.jsonwebtoken.Claims;
+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.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import javax.servlet.http.HttpServletRequest;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:多端登录 / 会话互斥(能力键 {@code auth.session.mutex})。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-85 —— 「多端登录/会话互斥」:
+ *       同账号多设备场景下,新设备登录 → 旧设备踢下线 / 二次验证;边界:异常设备风控;退出清 token。
+ *       模块载体 {@code AuthController}。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.1 认证与用户({@code /api/auth} 登录/登出
+ *       token 生命周期);缺口定义见 {@code docs/superpowers/specs/2026-11-07-parent-rest-contract.md}
+ *       §4 缺口 G-5 与 {@code docs/superpowers/specs/2026-11-07-cross-role-contract.md} §3 缺口 X-G-2:
+ *       「JWT 为无状态(JwtInterceptor 仅校验签名/过期),无会话注册表、无旧 token 吊销/踢出逻辑;
+ *       建议后端新增会话注册(user → 唯一 token/sessionId 映射,新登录失效旧 token)+
+ *       {@code POST /api/auth/logout}」。</li>
+ * </ul>
+ *
+ * <p><b>契约 AC(本类断言面)</b></p>
+ * <ol>
+ *   <li>AC-1 正常流:{@code POST /api/auth/logout} 存在(登出载体),返回 {@code Result},
+ *       能取到当前请求以定位待吊销 token。</li>
+ *   <li>AC-2 正常流:登录签发 token 携带会话标识 claim({@code sid}),且每次登录唯一
+ *       —— 无会话标识则无法区分设备、无法实现单端互斥。</li>
+ *   <li>AC-3 正常流:存在会话注册表组件(user → 唯一 sid 映射,见 AC-5 候选类名),
+ *       并被 {@code AuthController} 注入用于登录登记。</li>
+ *   <li>AC-4 边界流(互斥):注册表提供「按 userId 绑定唯一会话」+「按 (userId, sid) 校验活跃」两类方法
+ *       —— 新设备登录覆盖旧 sid → 旧设备 token 校验失败(踢下线)。</li>
+ *   <li>AC-5 边界流(踢出执行点):{@code JwtInterceptor} 注入会话校验组件,解析 token 后校验 sid 是否活跃,
+ *       失效 → 401(前端据此清登录态并回登录页)。</li>
+ *   <li>AC-6 边界流(异常设备风控):登录结果暴露「需二次验证」标记,供前端对异常设备走二次验证。</li>
+ *   <li>AC-7 异常流(退出清 token):注册表提供吊销/清除会话方法(登出后旧 token 立即失效)。</li>
+ *   <li>AC-8 权限流:登出接口必须登录 —— 不得列入 {@code PUBLIC_PATHS},且须列入
+ *       {@code LOGIN_REQUIRED_PATHS}(无 token 匿名请求直接 401,不得降级为匿名 userId=-1 放行)。</li>
+ * </ol>
+ *
+ * <p><b>实现现状(RED 依据,2026-11-15 实测 origin/cfclub)</b>:{@code AuthController} 无 {@code /logout};
+ * {@code JwtConfig} 仅写 {@code userId}/{@code role}/{@code roles}/{@code mpCode},无会话标识;
+ * 全仓无 tokenVersion / kickout / blacklist / 会话注册表;{@code JwtInterceptor} 无会话校验依赖;
+ * {@code LoginResultDTO} 无二次验证标记;{@code LOGIN_REQUIRED_PATHS} 无 {@code /api/auth/logout}
+ * (无 token 时该路径走「匿名放行」分支,实测 preHandle 返回 true)。故本类断言在实现补齐前应红。</p>
+ *
+ * <p><b>GREEN 落地清单(本类断言的最小契约面,命名可按此对齐)</b></p>
+ * <ol>
+ *   <li>{@code AuthController}:{@code @PostMapping("/logout") public Result<?> logout(HttpServletRequest request)},
+ *       从 {@code Authorization: Bearer} 取 token → 吊销当前会话。</li>
+ *   <li>{@code JwtConfig.generateToken(...)}:写入 claim {@code sid}(每次登录新生成,唯一标识一次登录会话)。</li>
+ *   <li>新增会话注册表组件,推荐类名 {@code com.etotem.cfc.service.UserSessionService}
+ *       (候选:{@code SessionRegistryService} / {@code TokenSessionService} / {@code AuthSessionService}),
+ *       提供 {@code bindSession(Long userId, String sid)} / {@code isActive(Long userId, String sid)} /
+ *       {@code revoke(Long userId, String sid)}(或语义等价命名)。</li>
+ *   <li>{@code JwtInterceptor}:注入该注册表,token 解析后校验 sid 活跃,失效 → 401(body 含 {@code "code":401})。</li>
+ *   <li>{@code LoginResultDTO}:暴露异常设备二次验证标记({@code needSecondVerify} 等)。</li>
+ *   <li>{@code JwtInterceptor.LOGIN_REQUIRED_PATHS}:加入 {@code "/api/auth/logout"}。</li>
+ * </ol>
+ *
+ * <p><b>测试手法</b>:纯反射 / Mockito,无 Spring 容器、无 DB、无 Redis、无外部服务;
+ * 与同批 RED 契约测试({@code TeamProfitHookContractTest} / {@code FamilyJoinValidationRulesContractTest})风格一致。</p>
+ */
+class AuthSessionMutexContractTest {
+
+    private static final String AUTH_CONTROLLER = "com.etotem.cfc.controller.auth.AuthController";
+
+    /** 会话注册表组件候选类名(第一个为推荐命名,GREEN 阶段任选其一即可) */
+    private static final String[] SESSION_REGISTRY_CANDIDATES = {
+            "com.etotem.cfc.service.UserSessionService",
+            "com.etotem.cfc.service.SessionRegistryService",
+            "com.etotem.cfc.service.TokenSessionService",
+            "com.etotem.cfc.service.AuthSessionService",
+            "com.etotem.cfc.security.UserSessionRegistry"
+    };
+
+    /** 登录族端点(多端登录入口,Signing side 需登记会话) */
+    private static final String[] LOGIN_PATHS = {
+            "/phone-login", "/wechat-login", "/silent-login", "/auto-login", "/sso-login"
+    };
+
+    private static final String LOGOUT_PATH = "/logout";
+
+    /** 会话标识 claim 候选键(契约取 sid,兼容 jti) */
+    private static final String[] SESSION_ID_CLAIMS = {"sid", "jti", "sessionId", "session_id"};
+
+    /** 异常设备风控二次验证标记候选字段 */
+    private static final String[] SECOND_VERIFY_FIELDS = {
+            "needSecondVerify", "needsSecondVerify", "secondVerifyRequired",
+            "verifyRequired", "needVerify", "needDeviceVerify", "deviceVerifyRequired"
+    };
+
+    @Mock
+    private JwtConfig jwtConfig;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================================================================
+    // 正常流 AC-1:登出端点存在(token 生命周期收口)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-1:AuthController 应提供 POST /api/auth/logout 登出端点")
+    void normal_logoutEndpoint_declared() {
+        Class<?> controller = loadClass(AUTH_CONTROLLER);
+        assertNotNull(controller, "[auth.session.mutex] AuthController 类不存在:" + AUTH_CONTROLLER);
+
+        Method logout = findMethodByPostPath(controller, LOGOUT_PATH);
+        if (logout == null) {
+            fail("[auth.session.mutex] AuthController 缺少 @PostMapping(\"" + LOGOUT_PATH + "\") 登出端点:"
+                    + "SC-P-85 要求「退出清 token」,契约设计(缺口 G-5 / X-G-2)明确需新增 POST /api/auth/logout。"
+                    + "已声明映射:" + describedPostPaths(controller));
+        }
+        assertEquals(Result.class, logout.getReturnType(),
+                "[auth.session.mutex] /api/auth/logout 应返回统一 Result(code/message/data)。");
+        boolean takesRequest = false;
+        for (Class<?> p : logout.getParameterTypes()) {
+            if (HttpServletRequest.class.isAssignableFrom(p)) {
+                takesRequest = true;
+                break;
+            }
+        }
+        assertTrue(takesRequest,
+                "[auth.session.mutex] /api/auth/logout 应接收 HttpServletRequest,以便从 Authorization Header "
+                        + "取出当前 token 并吊销该会话(登出清 token)。");
+    }
+
+    // ==================================================================
+    // 正常流 AC-2:token 会话标识(互斥/踢出的判定依据)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-2:登录 token 应携带会话标识 claim(sid),且每次登录唯一")
+    void normal_loginToken_carriesUniqueSessionIdClaim() {
+        JwtConfig config = newJwtConfig("contract-test-secret-key-0123456789abcdef", 7200_000L);
+
+        String first = config.generateToken(1001L, "parent");
+        String second = config.generateToken(1001L, "parent");
+
+        Claims firstClaims = config.parseToken(first);
+        String firstSid = firstSessionId(firstClaims);
+        assertNotNull(firstSid,
+                "[auth.session.mutex] token claims 缺少会话标识(sid):当前 claims=" + claimKeys(firstClaims)
+                        + "。无会话标识则无法区分设备会话,无法实现「新设备登录 → 旧设备踢下线」"
+                        + "(缺口 G-5 / X-G-2:会话注册 user → 唯一 token/sessionId 映射)。");
+
+        String secondSid = firstSessionId(config.parseToken(second));
+        assertNotNull(secondSid,
+                "[auth.session.mutex] 第二次登录 token 同样应携带会话标识(sid)。");
+        assertFalse(firstSid.equals(secondSid),
+                "[auth.session.mutex] 同一 userId 的两次登录必须生成不同 sid(每次登录代表一个独立会话),"
+                        + "否则无法判定哪一端是被踢下线的旧会话;实际两次均为 sid=" + firstSid);
+    }
+
+    // ==================================================================
+    // 正常流 AC-3:会话注册表组件存在,且登录侧注入使用
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-3:会话注册表组件应存在并被 AuthController 注入(登录登记会话)")
+    void normal_sessionRegistry_existsAndInjectedByLoginController() {
+        Class<?> registry = findSessionRegistryClass();
+        assertNotNull(registry,
+                "[auth.session.mutex] 会话注册表组件不存在:契约要求 user → 唯一 token/sessionId 映射"
+                        + "(新登录失效旧 token),推荐类名 com.etotem.cfc.service.UserSessionService,"
+                        + "候选:" + Arrays.toString(SESSION_REGISTRY_CANDIDATES)
+                        + "。当前仓库无任何会话注册/吊销载体(缺口 G-5 / X-G-2)。");
+
+        Class<?> controller = loadClass(AUTH_CONTROLLER);
+        assertNotNull(controller, "[auth.session.mutex] AuthController 类不存在:" + AUTH_CONTROLLER);
+        assertTrue(hasFieldOfType(controller, registry) || hasSessionLikeField(controller),
+                "[auth.session.mutex] AuthController 未注入会话注册表(" + registry.getSimpleName() + "):"
+                        + "登录成功签发 token 时必须登记会话(user → sid),否则互斥无判定依据。"
+                        + "当前字段:" + describedFieldTypes(controller));
+    }
+
+    // ==================================================================
+    // 边界流 AC-4:注册表「覆盖式绑定 + 活跃校验」= 新设备踢旧设备
+    // ==================================================================
+
+    @Test
+    @DisplayName("边界流-1:注册表应支持按 userId 绑定唯一会话并按 sid 校验活跃(新登录覆盖旧会话)")
+    void boundary_registry_singleSessionPerUser() {
+        Class<?> registry = findSessionRegistryClass();
+        if (registry == null) {
+            fail("[auth.session.mutex] 会话注册表组件不存在,无法验证单端互斥语义"
+                    + "(同一 userId 仅保留一个活跃 sid;新登录覆盖旧会话 → 旧设备踢下线)。"
+                    + "推荐类名 com.etotem.cfc.service.UserSessionService。");
+        }
+
+        assertTrue(hasBindMethod(registry),
+                "[auth.session.mutex] " + registry.getSimpleName()
+                        + " 缺少「按 userId 绑定会话」方法(推荐 bindSession(Long userId, String sid)):"
+                        + "同一 userId 只保留一个活跃 sid,新登录覆盖旧会话。当前方法:"
+                        + describedMethodNames(registry));
+        assertTrue(hasActiveCheckMethod(registry),
+                "[auth.session.mutex] " + registry.getSimpleName()
+                        + " 缺少「按 (userId, sid) 校验会话活跃」方法(推荐 isActive(Long userId, String sid)):"
+                        + "JwtInterceptor 需据此判定旧设备 token 是否已被踢下线。当前方法:"
+                        + describedMethodNames(registry));
+    }
+
+    // ==================================================================
+    // 边界流 AC-5:踢下线执行点(JwtInterceptor 会话校验)
+    // ==================================================================
+
+    @Test
+    @DisplayName("边界流-2:JwtInterceptor 应注入会话校验组件(旧设备被踢 token → 401)")
+    void boundary_jwtInterceptor_enforcesSessionMutex() {
+        Class<?> registry = findSessionRegistryClass();
+        assertNotNull(registry,
+                "[auth.session.mutex] 会话注册表组件不存在:JwtInterceptor 无从校验 token 所属会话是否仍活跃,"
+                        + "「旧设备踢下线」无法生效(当前 JwtInterceptor 仅校验签名/过期)。");
+
+        Class<?> interceptor = JwtInterceptor.class;
+        boolean injected = hasFieldOfType(interceptor, registry) || hasSessionLikeField(interceptor);
+        assertTrue(injected,
+                "[auth.session.mutex] JwtInterceptor 未注入会话注册表(" + registry.getSimpleName() + "):"
+                        + "token 解析成功后必须校验 sid 是否活跃,失效 → 401 并返回统一 code=401"
+                        + "(前端据此清登录态回登录页)。当前字段:" + describedFieldTypes(interceptor));
+    }
+
+    // ==================================================================
+    // 边界流 AC-6:异常设备风控(二次验证)
+    // ==================================================================
+
+    @Test
+    @DisplayName("边界流-3:异常设备登录应暴露二次验证标记(风控边界)")
+    void boundary_abnormalDevice_requiresSecondVerification() {
+        boolean declared = false;
+        List<String> actual = new ArrayList<>();
+        for (Field f : LoginResultDTO.class.getDeclaredFields()) {
+            actual.add(f.getName());
+            for (String candidate : SECOND_VERIFY_FIELDS) {
+                if (f.getName().equalsIgnoreCase(candidate)) {
+                    declared = true;
+                }
+            }
+        }
+        assertTrue(declared,
+                "[auth.session.mutex] SC-P-85 边界「异常设备风控」无载体:LoginResultDTO 应暴露"
+                        + "二次验证标记(推荐 needSecondVerify:Boolean),供前端对异常设备走二次验证后再放行登录。"
+                        + "当前字段:" + actual);
+    }
+
+    // ==================================================================
+    // 异常流 AC-7:退出清 token(吊销接口)
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流-1:注册表应提供吊销/清除会话方法(登出后旧 token 立即失效)")
+    void exception_logout_revokesSession() {
+        Class<?> registry = findSessionRegistryClass();
+        if (registry == null) {
+            fail("[auth.session.mutex] 会话注册表组件不存在,无法验证「退出清 token」:"
+                    + "SC-P-85 要求登出后旧 token 立即失效(服务端吊销,而非仅前端清本地缓存)。"
+                    + "推荐类名 com.etotem.cfc.service.UserSessionService。");
+        }
+        assertTrue(hasRevokeMethod(registry),
+                "[auth.session.mutex] " + registry.getSimpleName()
+                        + " 缺少吊销/清除会话方法(推荐 revoke(Long userId, String sid)):"
+                        + "POST /api/auth/logout 必须服务端吊销当前会话,登出后旧 token 再来请求应 401。"
+                        + "当前方法:" + describedMethodNames(registry));
+    }
+
+    // ==================================================================
+    // 权限流 AC-8:登出必须登录(不得匿名放行)
+    // ==================================================================
+
+    @Test
+    @DisplayName("权限流-1:/api/auth/logout 不得列入公开白名单(无 token 匿名请求必须 401)")
+    void permission_logout_mustRequireLogin() throws Exception {
+        String[] publicPaths = readStaticStringArray(JwtInterceptor.class, "PUBLIC_PATHS");
+        assertFalse(Arrays.asList(publicPaths).contains("/api/auth/logout"),
+                "[auth.session.mutex] /api/auth/logout 不得列入 JwtInterceptor.PUBLIC_PATHS:"
+                        + "登出属凭证操作,必须携带有效 token 才能吊销对应会话。");
+
+        String[] loginRequiredPaths = readStaticStringArray(JwtInterceptor.class, "LOGIN_REQUIRED_PATHS");
+        assertTrue(Arrays.asList(loginRequiredPaths).contains("/api/auth/logout"),
+                "[auth.session.mutex] /api/auth/logout 应列入 JwtInterceptor.LOGIN_REQUIRED_PATHS:"
+                        + "否则无 token 请求会被降级为匿名(userId=-1)放行到 controller,"
+                        + "「退出清 token」对未登录态无意义且口径漂移。"
+                        + "当前 LOGIN_REQUIRED_PATHS=" + Arrays.toString(loginRequiredPaths));
+    }
+
+    @Test
+    @DisplayName("权限流-2:无 token 调用 /api/auth/logout 应直接 401(不得匿名放行)")
+    void permission_logout_withoutToken_returns401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/auth/logout");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result,
+                "[auth.session.mutex] 无 token 调用 /api/auth/logout 必须被拦截:当前 preHandle 返回 true"
+                        + "(匿名 userId=-1 放行),登出接口对未登录请求无意义且会污染匿名上下文。");
+        assertEquals(401, response.getStatus(),
+                "[auth.session.mutex] 无 token 调用登出接口应返回 401。");
+        assertTrue(response.getContentAsString().contains("\"code\":401"),
+                "[auth.session.mutex] 应返回统一 code=401,实际: " + response.getContentAsString());
+    }
+
+    // ==================================================================
+    // 反射 / 静态辅助
+    // ==================================================================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Class<?> findSessionRegistryClass() {
+        for (String candidate : SESSION_REGISTRY_CANDIDATES) {
+            Class<?> clazz = loadClass(candidate);
+            if (clazz != null) {
+                return clazz;
+            }
+        }
+        return null;
+    }
+
+    /** 找到 @PostMapping 值命中指定路径的方法 */
+    private Method findMethodByPostPath(Class<?> clazz, String path) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping mapping = m.getAnnotation(PostMapping.class);
+            if (mapping == null) {
+                continue;
+            }
+            for (String value : mapping.value()) {
+                if (normalize(value).equals(normalize(path))) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
+    private String normalize(String path) {
+        if (path == null) {
+            return "";
+        }
+        String p = path.trim();
+        return p.startsWith("/") ? p : "/" + p;
+    }
+
+    private List<String> describedPostPaths(Class<?> clazz) {
+        List<String> paths = new ArrayList<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping mapping = m.getAnnotation(PostMapping.class);
+            if (mapping != null) {
+                paths.addAll(Arrays.asList(mapping.value()));
+            }
+        }
+        return paths;
+    }
+
+    private boolean hasFieldOfType(Class<?> clazz, Class<?> type) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getType().equals(type)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 宽松探测:字段类型或字段名带会话互斥语义(允许实现者自命名组件) */
+    private boolean hasSessionLikeField(Class<?> clazz) {
+        for (Field f : clazz.getDeclaredFields()) {
+            String typeName = f.getType().getSimpleName().toLowerCase();
+            String name = f.getName().toLowerCase();
+            boolean typeHit = (typeName.contains("session") || typeName.contains("token"))
+                    && (typeName.contains("registry") || typeName.contains("store") || typeName.contains("guard")
+                    || typeName.contains("mutex") || typeName.contains("valid") || typeName.contains("revoke")
+                    || typeName.contains("blacklist"));
+            boolean nameHit = name.contains("session") && (name.contains("valid") || name.contains("guard")
+                    || name.contains("registry") || name.contains("mutex"));
+            if (typeHit || nameHit) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private List<String> describedFieldTypes(Class<?> clazz) {
+        List<String> types = new ArrayList<>();
+        for (Field f : clazz.getDeclaredFields()) {
+            types.add(f.getType().getSimpleName() + " " + f.getName());
+        }
+        return types;
+    }
+
+    private List<String> describedMethodNames(Class<?> clazz) {
+        List<String> names = new ArrayList<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            names.add(m.getName());
+        }
+        return names;
+    }
+
+    /** 绑定方法:名称含 bind/register/create/open/activate/issue 语义,且至少 (userId, sid) 两个入参 */
+    private boolean hasBindMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            boolean nameHit = name.contains("bind") || name.contains("register") || name.contains("create")
+                    || name.contains("open") || name.contains("activate") || name.contains("issue");
+            if (!nameHit) {
+                continue;
+            }
+            Class<?>[] params = m.getParameterTypes();
+            if (params.length >= 2 && (params[0].equals(Long.class) || params[0].equals(long.class))
+                    && params[1].equals(String.class)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 活跃校验方法:名称含 isActive/valid/check/exists 语义,返回 boolean */
+    private boolean hasActiveCheckMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            boolean nameHit = name.contains("isactive") || name.contains("active") || name.contains("valid")
+                    || name.contains("alive") || name.contains("check") || name.contains("exists")
+                    || name.contains("match");
+            if (!nameHit) {
+                continue;
+            }
+            Class<?> ret = m.getReturnType();
+            if ((ret.equals(boolean.class) || ret.equals(Boolean.class)) && m.getParameterTypes().length >= 1) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 吊销方法:名称含 revoke/invalidate/clear/remove/delete/kill/logout 语义 */
+    private boolean hasRevokeMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("revoke") || name.contains("invalidate") || name.contains("clear")
+                    || name.contains("remove") || name.contains("delete") || name.contains("kill")
+                    || name.contains("logout") || name.contains("expire")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String firstSessionId(Claims claims) {
+        for (String key : SESSION_ID_CLAIMS) {
+            Object value = claims.get(key);
+            if (value != null && value.toString().trim().length() > 0) {
+                return value.toString().trim();
+            }
+        }
+        return null;
+    }
+
+    private List<String> claimKeys(Claims claims) {
+        return new ArrayList<>(claims.keySet());
+    }
+
+    private String[] readStaticStringArray(Class<?> clazz, String fieldName) throws Exception {
+        Field field = clazz.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return (String[]) field.get(null);
+    }
+
+    private JwtConfig newJwtConfig(String secret, long expirationMillis) {
+        JwtConfig config = new JwtConfig();
+        setField(config, "secret", secret);
+        setField(config, "expiration", expirationMillis);
+        return config;
+    }
+
+    private void setField(Object target, String name, Object value) {
+        try {
+            Field field = target.getClass().getDeclaredField(name);
+            field.setAccessible(true);
+            field.set(target, value);
+        } catch (Exception e) {
+            throw new IllegalStateException("无法注入测试字段 " + name + ":" + e.getMessage(), e);
+        }
+    }
+}

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

@@ -279,6 +279,52 @@ class FamilyChildContextSwitchContractTest {
         assertEquals("用户未加入家庭", ex.getMessage());
     }
 
+    /**
+     * <b>RED 用例(仅本家庭孩子可选)</b>:SC-P-73 边界明确「仅本家庭<b>孩子</b>可选」。
+     * 本家庭成年成员(managed adult,{@code userId=null},成年生日 → {@code effectiveRole=parent})
+     * 不是孩子上下文,不得被 {@code /api/family/member/switch} 选中。
+     *
+     * <p><b>当前实现</b>:{@code switchToMember} 仅拦截「切自己 / 可登录成员 / 跨家庭」,
+     * 未校验目标 {@code effectiveRole} 必须为 {@code child},本家庭成年成员会被直接放行,
+     * 返回 {@code effectiveRole=parent} 的「孩子上下文」,把家长/长辈上下文串进孩子态。
+     * <b>预期失败(红)</b>;GREEN 阶段补「目标必须为 child」校验后转绿。</p>
+     */
+    @Test
+    @DisplayName("RED-异常流-5:本家庭成年成员不得作为孩子上下文(仅本家庭孩子可选)")
+    void service_本家庭成年成员_不得作为孩子上下文() {
+        stubParent(PARENT_USER_ID);
+        // 本家庭成年成员:不可登录(userId=null),成年生日 → effectiveRole=parent
+        FamilyMember adultMember = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(60), "奶奶");
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(adultMember);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID),
+                "[family.childContext.switch] 本家庭成年成员(effectiveRole=parent)被当作孩子上下文放行:"
+                        + "SC-P-73 边界要求「仅本家庭孩子可选」,应拒绝非孩子目标");
+    }
+
+    @Test
+    @DisplayName("异常流-6:多孩子互切(孩子A → 孩子B)上下文只指向 B,均为 child")
+    void service_多孩子互切_上下文指向目标孩子B() {
+        stubParent(PARENT_USER_ID);
+        FamilyMember childA = managedChild(CHILD_A_MEMBER_ID, ageYearsAgo(10), "大宝");
+        FamilyMember childB = managedChild(CHILD_B_MEMBER_ID, ageYearsAgo(8), "二宝");
+        when(familyMemberMapper.selectById(CHILD_A_MEMBER_ID)).thenReturn(childA);
+        when(familyMemberMapper.selectById(CHILD_B_MEMBER_ID)).thenReturn(childB);
+
+        // 先在 A 的孩子上下文,再直接切到 B(多孩子互切,不必先回家长视角)
+        SwitchMemberVO voA = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_A_MEMBER_ID);
+        SwitchMemberVO voB = familyMemberService.switchToMember(PARENT_USER_ID, CHILD_B_MEMBER_ID);
+
+        assertEquals(CHILD_A_MEMBER_ID, voA.getMemberId());
+        assertEquals("child", voA.getEffectiveRole());
+        assertEquals(CHILD_B_MEMBER_ID, voB.getMemberId(),
+                "[family.childContext.switch] A→B 切换后上下文必须指向目标孩子 B");
+        assertEquals("child", voB.getEffectiveRole(),
+                "[family.childContext.switch] 多孩子互切后仍应处于孩子上下文(child)");
+        assertNotNull(voB.getNickname());
+    }
+
     // ====================================================================
     // Service 权限流(TC-P-73-P:双角色铁律——孩子端不能切回家长)
     // ====================================================================

+ 308 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GdprDataExportContractTest.java

@@ -0,0 +1,308 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.ChatMessageMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GrowthRecordMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:个人/家庭数据导出(GDPR,能力键 {@code data.export.gdpr},业务域 J)。
+ *
+ * <p><b>需求溯源</b>:{@code tests/user-scenarios/应用场景总览.md} SC-P-72
+ * (个人/家庭数据导出(GDPR)——目标:下载本人数据;流程:<b>申请导出 → 生成加密包 → 下载</b>;
+ * 边界:<b>敏感字段脱敏</b>;<b>频次限制</b>;<b>越权导出被拒</b>;模块:数据导出(GDPR))。</p>
+ *
+ * <p><b>设计溯源</b>:{@code docs/superpowers/api/API_REFERENCE.md} —— 数据主权(GDPR)能力
+ * <b>无独立端点</b>,需<b>复用</b> {@code /api/user}({@code POST /api/user/data-export},
+ * {@code AccountCleanupService.exportUserData})与 {@code /api/family/member/list} 汇集导出;
+ * 个保法可携带权导出(SC-T-29 / SC-A-19 同源口径)。</p>
+ *
+ * <p><b>实现现状(2026-11-15 核实)</b>:{@code src/main} 中:
+ * <ul>
+ *   <li>{@code AccountCleanupService.exportUserData} 仅返回<b>裸数据 Map</b>(userInfo 直出
+ *       {@code phone}/{@code realName} 明文,无脱敏),无申请/加密包/下载三阶段载体,
+ *       无频次限制、无越权校验;</li>
+ *   <li>全库无 {@code GdprDataExportService} 宿主类、无 {@code data_export_request} 表
+ *       (schema.sql 0 命中)。</li>
+ * </ul>
+ * 因此本测试以「契约行为缺失」为断言核心:能编译通过、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者落地 GDPR 宿主 {@code GdprDataExportService}
+ * (含 {@code requestExport}/{@code generatePackage}/{@code downloadPackage}/
+ * {@code assertWithinFrequencyLimit}/{@code assertCanExport})、在 {@code schema.sql} 增加
+ * {@code data_export_request} 表、并让 {@code AccountCleanupService.exportUserData} 对敏感字段
+ * (phone/realName)脱敏、返回导出包载体({@code downloadToken}/{@code packageStatus})后,
+ * 本测试自动转绿。</p>
+ *
+ * <p><b>权限流</b>:本能力权限流<b>适用</b>——SC-P-72 边界「越权导出被拒」即权限维度
+ * (仅本人/本家庭可导出,跨家庭/跨成员导出被拒),以 {@link #unauthorizedExport_hasGuard()}
+ * 用例覆盖。</p>
+ */
+class GdprDataExportContractTest {
+
+    /** 契约宿主:数据导出(GDPR) 模块服务类(设计复用 /api/user + /api/family/member/list 汇集导出)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.GdprDataExportService";
+
+    /** SC-P-72 边界:导出频次限制需持久化载体(申请记录/频次窗口/有效期/下载令牌)。 */
+    private static final String EXPORT_REQUEST_TABLE = "data_export_request";
+
+    // ==================== Mockito 行为探测载体(现有 /api/user/data-export 实现) ====================
+
+    private AccountCleanupService cleanupService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        cleanupService = new AccountCleanupService();
+        inject(cleanupService, "userMapper", mock(UserMapper.class));
+        inject(cleanupService, "danAssessmentResultMapper", mock(DanAssessmentResultMapper.class));
+        inject(cleanupService, "healthReportMapper", mock(HealthReportMapper.class));
+        inject(cleanupService, "chatMessageMapper", mock(ChatMessageMapper.class));
+        inject(cleanupService, "familyMemberMapper", mock(FamilyMemberMapper.class));
+        inject(cleanupService, "assessmentOrderMapper", mock(AssessmentOrderMapper.class));
+        inject(cleanupService, "paymentOrderMapper", mock(PaymentOrderMapper.class));
+        inject(cleanupService, "taskMapper", mock(TaskMapper.class));
+        inject(cleanupService, "growthRecordMapper", mock(GrowthRecordMapper.class));
+    }
+
+    // ==================== 正常流:申请导出 → 生成加密包 → 下载(宿主契约,反射) ====================
+
+    @Test
+    @DisplayName("正常流-1:GdprDataExportService 宿主类应存在(数据导出(GDPR) 模块)")
+    void hostClass_exists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[data.export.gdpr] GdprDataExportService 类不存在:SC-P-72 要求" +
+                        "「申请导出 → 生成加密包 → 下载」三阶段 GDPR 导出能力。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:宿主应提供 requestExport(userId) 申请导出入口")
+    void host_requestExportEntry() {
+        Object host = contractHost();
+        boolean hasRequest = false;
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals("requestExport")) {
+                hasRequest = true;
+                break;
+            }
+        }
+        assertTrue(hasRequest,
+                "[data.export.gdpr] GdprDataExportService 应提供 requestExport(Long userId)" +
+                        "申请导出入口(SC-P-72 流程第一步)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:宿主应提供 generatePackage(requestId) 生成加密包")
+    void host_generatePackage() {
+        Object host = contractHost();
+        assertMethod(host, "generatePackage",
+                "[data.export.gdpr] GdprDataExportService 应提供 generatePackage(Long requestId)" +
+                        "生成加密包(SC-P-72 流程第二步)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:宿主应提供 downloadPackage(requestId, userId) 下载(含归属校验)")
+    void host_downloadPackage() {
+        Object host = contractHost();
+        assertMethod(host, "downloadPackage",
+                "[data.export.gdpr] GdprDataExportService 应提供 downloadPackage(Long requestId, Long userId)" +
+                        "下载加密包并校验归属(SC-P-72 流程第三步 + 越权导出被拒)。");
+    }
+
+    // ==================== 正常流:现有 /api/user/data-export 应返回 GDPR 导出包载体 ====================
+
+    @Test
+    @DisplayName("正常流-5:/api/user/data-export 应返回导出包载体(downloadToken/packageStatus),而非裸数据")
+    void existingExport_returnsPackageCarrier() throws Exception {
+        Map<String, Object> data = exportOf(1L);
+        // SC-P-72 流程:申请后生成加密包 → 下载。导出结果必须携带包下载载体(下载令牌 + 包状态),
+        // 而不是当前裸数据 Map(无 downloadToken/packageStatus)。
+        assertTrue(data.containsKey("downloadToken"),
+                "[data.export.gdpr] exportUserData 应返回 downloadToken(加密包下载令牌)——" +
+                        "当前仅返回裸数据 Map,缺「申请→加密包→下载」载体(SC-P-72 流程)。");
+        assertTrue(data.containsKey("packageStatus"),
+                "[data.export.gdpr] exportUserData 应返回 packageStatus(加密包状态 pending/ready)——" +
+                        "当前裸数据无包状态(SC-P-72 流程)。");
+    }
+
+    // ==================== 边界流:敏感字段脱敏 ====================
+
+    @Test
+    @DisplayName("边界流-1:导出的手机号必须脱敏(含 *),禁止明文直出")
+    void sensitivePhone_masked() throws Exception {
+        Map<String, Object> userInfo = userInfoOf(exportOf(1L));
+        Object phone = userInfo.get("phone");
+        assertNotNull(phone, "[data.export.gdpr] GDPR 导出包应含本人手机号字段(脱敏后)");
+        assertTrue(String.valueOf(phone).contains("*"),
+                "[data.export.gdpr] 敏感字段 phone 必须脱敏(如 138****8000),当前明文直出:"
+                        + phone + "(SC-P-72 边界「敏感字段脱敏」)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:导出的真实姓名必须脱敏(姓氏+某),禁止明文直出")
+    void sensitiveRealName_masked() throws Exception {
+        Map<String, Object> userInfo = userInfoOf(exportOf(1L));
+        Object realName = userInfo.get("realName");
+        assertNotNull(realName, "[data.export.gdpr] GDPR 导出包应含本人真实姓名字段(脱敏后)");
+        assertTrue(String.valueOf(realName).contains("某") || String.valueOf(realName).contains("*"),
+                "[data.export.gdpr] 敏感字段 realName 必须脱敏(如 张×),当前明文直出:"
+                        + realName + "(SC-P-72 边界「敏感字段脱敏」)。");
+    }
+
+    // ==================== 边界流:频次限制 ====================
+
+    @Test
+    @DisplayName("边界流-3:宿主应提供 assertWithinFrequencyLimit(userId) 频次守卫")
+    void frequencyLimit_guardExists() {
+        Object host = contractHost();
+        assertMethod(host, "assertWithinFrequencyLimit",
+                "[data.export.gdpr] GdprDataExportService 应提供 assertWithinFrequencyLimit(Long userId)" +
+                        "频次限制守卫(SC-P-72 边界「频次限制」:窗口内多次申请导出被拒)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:schema.sql 应含 data_export_request 表(申请/频次窗口/有效期/下载令牌)")
+    void frequencyLimit_persisted() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(EXPORT_REQUEST_TABLE),
+                "[data.export.gdpr] schema.sql 缺少 " + EXPORT_REQUEST_TABLE
+                        + " 建表:GDPR 申请导出需持久化申请记录/频次窗口/包有效期/下载令牌" +
+                        "(SC-P-72 流程「申请导出→加密包→下载」+ 边界「频次限制」)。");
+    }
+
+    // ==================== 边界流:越权导出被拒(权限流) ====================
+
+    @Test
+    @DisplayName("边界流-5:宿主应提供 assertCanExport(requesterUserId, targetFamilyId) 越权守卫")
+    void unauthorizedExport_hasGuard() {
+        Object host = contractHost();
+        assertMethod(host, "assertCanExport",
+                "[data.export.gdpr] GdprDataExportService 应提供 assertCanExport(Long requesterUserId," +
+                        " Long targetFamilyId) 越权守卫:仅本人/本家庭可导出," +
+                        "跨家庭/跨成员导出必须被拒(SC-P-72 边界「越权导出被拒」= 权限流)。");
+    }
+
+    // ==================== 反射/Mockito 辅助 ====================
+
+    private Object contractHost() {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("[data.export.gdpr] GdprDataExportService 类不存在,无法验证 GDPR 契约(SC-P-72)。");
+        }
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            fail("[data.export.gdpr] GdprDataExportService 实例化失败(契约宿主须有无参构造): " + e);
+            return null;
+        }
+    }
+
+    private void assertMethod(Object host, String name, String msg) {
+        boolean found = false;
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                found = true;
+                break;
+            }
+        }
+        assertTrue(found, msg);
+    }
+
+    /** 现有 /api/user/data-export 载体:构造 userMapper mock,调用 exportUserData。 */
+    private Map<String, Object> exportOf(long userId) throws Exception {
+        UserMapper userMapper = (UserMapper) field(cleanupService, "userMapper");
+        when(userMapper.selectById(userId)).thenReturn(testUser());
+        when(((com.etotem.cfc.mapper.FamilyMemberMapper) field(cleanupService, "familyMemberMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.DanAssessmentResultMapper) field(cleanupService, "danAssessmentResultMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.HealthReportMapper) field(cleanupService, "healthReportMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.TaskMapper) field(cleanupService, "taskMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        when(((com.etotem.cfc.mapper.GrowthRecordMapper) field(cleanupService, "growthRecordMapper"))
+                .selectList(any())).thenReturn(Collections.emptyList());
+        return cleanupService.exportUserData(userId);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> userInfoOf(Map<String, Object> data) {
+        Object info = data.get("userInfo");
+        assertNotNull(info, "[data.export.gdpr] exportUserData 应返回 userInfo 块");
+        return (Map<String, Object>) info;
+    }
+
+    private User testUser() {
+        User u = new User();
+        u.setId(1L);
+        u.setFamilyId(100L);
+        u.setNickname("测试用户");
+        u.setPhone("13800138000");
+        u.setRealName("张小明");
+        u.setGender("male");
+        u.setBirthday(new Date());
+        u.setFamilyRole("parent");
+        u.setMemberLevel("standard");
+        return u;
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Object field(Object target, String field) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}