소스 검색

test(guest-sc): 补齐游客域 SC-G-01/03/04/05/07/08/09/10/11/12 三类流契约执行证据(cfclub-otask)

- 新增 FrontendSourceLocator:前端源定位工具(双 CWD 兼容,前端域契约断言用)
- 新增 10 个 JUnit 契约测试类,覆盖 10 个未跑通 SC 的正常流/异常流/权限流
- 证据模式沿用 SC-G-02/SC-G-06 已跑通(8051b64f/df2364ba3):Mockito 直调 handler + MockHttpServletRequest/Response + 前端源静态断言
- 实跑结果:71 例全绿(Tests run: 71, Failures: 0, Errors: 0)
- 纯测试证据补录,未写任何业务实现代码(端点 100% 已实现,仅核对)
Agnes Agent 5 일 전
부모
커밋
dceeaaeaf

+ 143 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG01LandingContractTest.java

@@ -0,0 +1,143 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.testutil.FrontendSourceLocator;
+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 static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-01 首次进入小程序/落地页 契约级单测(能力键 auth.guest.landing)
+ * ================================================================
+ * 覆盖:
+ * - 正常流 (TC-G-01):冷启动进入 splash → 落地页(config.js 存在默认 API 地址);页面不自动进入业务页。
+ * - 异常流 (TC-G-01-E):弱网/微信授权失败 → config.js try/catch 回退默认地址,不白屏(前端源契约)。
+ * - 权限流 (TC-G-01-P):游客无 token 访问公开路径放行(/api/product/list、/api/articles/list);
+ *   业务接口(/api/family/member/list)被 JwtInterceptor 拦截 → 401。
+ *
+ * 溯源:SC-G-01 · tests/user-scenarios/01-guest.md 场景 G-1 · tests/test-cases/guest.md TC-G-01/TC-G-01-E/TC-G-01-P
+ *
+ * 纯单元测试:Mockito + MockHttpServletRequest/Response(权限流走真实 JwtInterceptor 契约),
+ * 不依赖数据库 / 真实微信 / Spring 容器,保证可执行性与确定性。
+ */
+public class ScG01LandingContractTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-01:前端落地页链路存在(splash + 首页 + config.js 默认地址)
+    // ====================================================================
+
+    @Test
+    public void frontend_landingPagesExist() {
+        // 冷启动进入 splash,2 秒后 reLaunch 到 index-home —— 前端页面必须存在
+        assertTrue(FrontendSourceLocator.frontendSourceExists("pages/splash/index.vue"),
+                "启动页 pages/splash/index.vue 必须存在");
+        assertTrue(FrontendSourceLocator.frontendSourceExists("pages/index-home/index.vue"),
+                "落地页 pages/index-home/index.vue 必须存在");
+        String splash = FrontendSourceLocator.readFrontendSource("pages/splash/index.vue");
+        assertTrue(splash.contains("reLaunch"), "splash 应在展示后跳转落地页");
+        assertTrue(splash.contains("/pages/index-home/index"), "splash 应跳转 index-home 落地页");
+    }
+
+    @Test
+    public void frontend_configHasDefaultApiBaseUrl() {
+        // 落地页依赖 config.js 提供 API_BASE_URL;默认地址必须存在(非空)
+        String config = FrontendSourceLocator.readFrontendSource("config.js");
+        assertTrue(config.contains("API_BASE_URL"), "config.js 必须定义 API_BASE_URL");
+        assertTrue(config.contains("cfc"), "config.js 默认地址应含 cfc 域名");
+        assertTrue(config.contains("export default"), "config.js 必须导出配置对象");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-01-E:弱网/授权失败有兜底(config.js try/catch 回退默认地址,不白屏)
+    // ====================================================================
+
+    @Test
+    public void frontend_configFallsBackOnEnvDetectionFailure() {
+        // 环境探测失败(getAccountInfoSync 抛异常)→ catch 回退默认 API_BASE_URL,不崩溃不白屏
+        String config = FrontendSourceLocator.readFrontendSource("config.js");
+        assertTrue(config.contains("try {"), "config.js 必须用 try 包裹环境探测");
+        assertTrue(config.contains("catch"), "config.js 必须捕获环境探测异常(弱网/非微信)");
+        assertTrue(config.contains("getAccountInfoSync"), "环境探测须调用 getAccountInfoSync");
+    }
+
+    @Test
+    public void frontend_splashHasLoginEntryNotAutoEnterBusiness() {
+        // 落地页/首页不自动进入业务页(家庭/任务/能量)——splash 只 reLaunch 到 index-home
+        String splash = FrontendSourceLocator.readFrontendSource("pages/splash/index.vue");
+        assertFalse(splash.contains("/pages/family/index"), "splash 不应直接进家庭业务页");
+        assertFalse(splash.contains("/pages/tasks/index"), "splash 不应直接进任务业务页");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-01-P:游客无 token 公开路径放行;业务接口 401(不泄露内部结构)
+    // ====================================================================
+
+    @Test
+    public void permission_publicProductList_anonymousAllowed() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/product/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "公开商品列表路径无 token 应放行(游客可浏览)");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "公开路径不应写 401");
+    }
+
+    @Test
+    public void permission_publicArticlesList_anonymousAllowed() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/articles/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "公开文章列表路径无 token 应放行");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    @Test
+    public void permission_protectedFamilyMemberList_is401NoLeak() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "业务接口无 token 必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), "应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), "应含统一 code=401,实际: " + body);
+        assertTrue(body.contains("请先登录"), "应含统一 message,实际: " + body);
+        assertFalse(body.contains("Exception"), "不得泄露内部异常: " + body);
+        assertFalse(body.contains("at com.etotem"), "不得泄露调用堆栈: " + body);
+    }
+}

+ 312 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG03LoginMethodContractTest.java

@@ -0,0 +1,312 @@
+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.controller.admin.AdminAuthController;
+import com.etotem.cfc.controller.auth.AuthController;
+import com.etotem.cfc.dto.AdminLoginDTO;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.PhoneLoginDTO;
+import com.etotem.cfc.dto.SendCodeDTO;
+import com.etotem.cfc.dto.WechatPhoneLoginDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.LoginAttemptService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.VerificationCodeService;
+import com.etotem.cfc.service.WechatWebLoginService;
+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.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * SC-G-03 登录方式口径契约级单测(能力键 auth.guest.loginMethod)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-3、tests/test-cases/guest.md TC-G-03):
+ * - 小程序端:微信一键登录(wechat-login,SC-G-02 已覆盖)、微信手机号快捷登录(wechat-phone-login)、
+ *   验证码登录(phone-login / send-code,API 兼容保留:小程序端无入口,服务端能力保留)。
+ * - Web 管理后台:密码登录(login-by-password)、验证码登录(send-code + login)。
+ * - 异常流 (TC-G-03-E):验证码错误/过期 → 明确失败且不产生会话;手机号格式非法 → 400;不泄露堆栈。
+ * - 权限流 (TC-G-03-P):全部登录端点在 JwtInterceptor 公开白名单内,游客无 token 可触达(不误拦 401)。
+ *
+ * 溯源:SC-G-03 · tests/user-scenarios/01-guest.md 场景 G-3 · tests/test-cases/guest.md TC-G-03/TC-G-03-E
+ *
+ * 纯单元测试:Mockito 直接调用 handler + MockHttpServletRequest/Response,不依赖数据库/真实微信/Spring 容器。
+ */
+public class ScG03LoginMethodContractTest {
+
+    @Mock
+    private UserService userService;
+    @Mock
+    private VerificationCodeService verificationCodeService;
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private AuthController authController;
+
+    @Mock
+    private LoginAttemptService loginAttemptService;
+    @Mock
+    private WechatWebLoginService wechatWebLoginService;
+    @InjectMocks
+    private AdminAuthController adminAuthController;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-03:验证码登录(API 兼容保留:服务端能力)
+    // ====================================================================
+
+    @Test
+    public void sendCode_ValidPhone_GeneratesCode() {
+        SendCodeDTO dto = new SendCodeDTO();
+        dto.setPhone("13800000001");
+        dto.setType("login");
+
+        Result<Boolean> result = authController.sendCode(dto);
+
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData(), "发送验证码应返回 true");
+        verify(verificationCodeService, times(1)).generateCode("13800000001", "login");
+    }
+
+    @Test
+    public void phoneLogin_ValidCode_ReturnsToken() {
+        PhoneLoginDTO dto = new PhoneLoginDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("123456");
+
+        when(verificationCodeService.verifyCode("13800000001", "123456", "login")).thenReturn(true);
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setToken("jwt-token");
+        login.setUserId(99L);
+        login.setRole("parent");
+        when(userService.phoneLogin(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = authController.phoneLogin(dto);
+
+        assertEquals(200, result.getCode());
+        assertEquals("jwt-token", result.getData().getToken());
+        assertEquals("parent", result.getData().getRole());
+        verify(verificationCodeService, times(1)).verifyCode("13800000001", "123456", "login");
+        verify(userService, times(1)).phoneLogin(dto);
+    }
+
+    @Test
+    public void wechatPhoneLogin_ValidInput_ReturnsToken() {
+        WechatPhoneLoginDTO dto = new WechatPhoneLoginDTO();
+        dto.setCode("wx-code");
+        dto.setPhoneCode("phone-code");
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setToken("wxtoken");
+        login.setUserId(77L);
+        login.setRole("parent");
+        when(userService.wechatPhoneLogin(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = authController.wechatPhoneLogin(dto);
+
+        assertEquals(200, result.getCode());
+        assertEquals("wxtoken", result.getData().getToken());
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-03:Web 管理后台密码登录(login-by-password)
+    // ====================================================================
+
+    @Test
+    public void adminLoginByPassword_ValidCredential_ReturnsToken() {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone("13800000001");
+        dto.setPassword("password123");
+
+        // 预置后台角色用户,密码以 BCrypt 存储(兼容逻辑,verifyLoginPassword 支持 MD5+BCrypt)
+        User admin = new User();
+        admin.setId(5L);
+        admin.setPhone("13800000001");
+        admin.setRole("admin");
+        admin.setPassword("{bcrypt}" + "$2a$10$abcdefghijklmnopqrstuv");
+        when(userMapper.selectOne(any())).thenReturn(admin);
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(userService.getUserRoles(5L)).thenReturn(java.util.Collections.singletonList("admin"));
+        when(jwtConfig.generateToken(eq(5L), anyList())).thenReturn("admin-jwt");
+
+        // 密码用正确哈希后验证:直接构造 BCrypt 匹配
+        org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder encoder =
+                new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
+        admin.setPassword(encoder.encode("password123"));
+
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(dto, req);
+
+        // 若 mock 链完整则 200 + token
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals("admin-jwt", result.getData().get("token"));
+    }
+
+    @Test
+    public void adminLoginByPassword_EmptyBody_Is400() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(null, req);
+        assertEquals(400, result.getCode(), "空 body 应返回业务码 400");
+        assertNull(result.getData());
+    }
+
+    @Test
+    public void adminLoginByPassword_MissingFields_Is400() {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone("13800000001");
+        dto.setPassword(null);
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        Result<java.util.Map<String, Object>> result = adminAuthController.loginByPassword(dto, req);
+        assertEquals(400, result.getCode(), "缺密码应返回业务码 400");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-03-E:验证码错误/手机号非法 → 明确失败且不产生会话
+    // ====================================================================
+
+    @Test
+    public void sendCode_InvalidPhone_ReturnsError() {
+        SendCodeDTO dto = new SendCodeDTO();
+        dto.setPhone("123"); // 非法格式
+        Result<Boolean> result = authController.sendCode(dto);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("格式不正确"), "手机号非法应提示格式错误,实际: " + result.getMessage());
+        verify(verificationCodeService, never()).generateCode(anyString(), anyString());
+    }
+
+    @Test
+    public void sendCode_EmptyPhone_ReturnsError() {
+        SendCodeDTO dto = new SendCodeDTO();
+        dto.setPhone("");
+        Result<Boolean> result = authController.sendCode(dto);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("不能为空"), "空手机号应提示,实际: " + result.getMessage());
+    }
+
+    @Test
+    public void phoneLogin_WrongCode_ReturnsErrorNoToken() {
+        PhoneLoginDTO dto = new PhoneLoginDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("000000");
+
+        when(verificationCodeService.verifyCode("13800000001", "000000", "login")).thenReturn(false);
+
+        Result<LoginResultDTO> result = authController.phoneLogin(dto);
+
+        assertEquals(500, result.getCode(), "验证码错误应返回业务码 500");
+        assertTrue(result.getMessage().contains("验证码错误"), "实际: " + result.getMessage());
+        assertNull(result.getData(), "验证码错误绝不产生会话(data 必须为 null)");
+        verify(userService, never()).phoneLogin(any());
+    }
+
+    @Test
+    public void phoneLogin_ServiceFailure_ErrorMessageDoesNotLeakStack() {
+        PhoneLoginDTO dto = new PhoneLoginDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("123456");
+        when(verificationCodeService.verifyCode(anyString(), anyString(), anyString())).thenReturn(true);
+        when(userService.phoneLogin(dto)).thenThrow(new RuntimeException("登录失败"));
+
+        Result<LoginResultDTO> result = authController.phoneLogin(dto);
+
+        String msg = result.getMessage();
+        assertNotNull(msg);
+        assertFalse(msg.contains("RuntimeException"), "不得泄露异常类名: " + msg);
+        assertFalse(msg.contains("at com."), "不得泄露堆栈: " + msg);
+    }
+
+    @Test
+    public void wechatPhoneLogin_MissingParams_ReturnsError() {
+        WechatPhoneLoginDTO dto = new WechatPhoneLoginDTO();
+        dto.setCode(null);
+        dto.setPhoneCode(null);
+        Result<LoginResultDTO> result = authController.wechatPhoneLogin(dto);
+        assertEquals(500, result.getCode());
+        assertNull(result.getData());
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-03-P:全部登录端点在公开白名单内(游客无 token 可触达)
+    // ====================================================================
+
+    @Test
+    public void permission_loginEndpoints_arePublicNotBlocked() throws Exception {
+        String[] publicPaths = {
+                "/api/auth/send-code",
+                "/api/auth/phone-login",
+                "/api/auth/wechat-phone-login",
+                "/api/auth/wechat-login",
+                "/api/auth/register-with-invite",
+                "/api/admin-auth/send-code",
+                "/api/admin-auth/login",
+                "/api/admin-auth/login-by-password",
+                "/api/admin-auth/account-roles",
+                "/api/admin-auth/wechat-scan-qr",
+                "/api/admin-auth/wechat-scan-poll",
+        };
+        for (String path : publicPaths) {
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setMethod("POST");
+            request.setRequestURI(path);
+            MockHttpServletResponse response = new MockHttpServletResponse();
+            boolean allowed = jwtInterceptor.preHandle(request, response, null);
+            assertTrue(allowed, "登录端点应为公开路径,游客可触达: " + path);
+            assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "公开登录端点不应写 401: " + path);
+        }
+    }
+
+    // ====================================================================
+    // 结构契约:统一 POST
+    // ====================================================================
+
+    @Test
+    public void structure_loginEndpointsUsePostMapping() throws Exception {
+        // 小程序端
+        for (String m : new String[]{"sendCode", "phoneLogin", "wechatPhoneLogin"}) {
+            for (Method method : AuthController.class.getDeclaredMethods()) {
+                if (method.getName().equals(m)) {
+                    assertNotNull(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class),
+                            "AuthController." + m + " 必须为 @PostMapping");
+                }
+            }
+        }
+        // 管理端
+        for (String m : new String[]{"sendCode", "login", "loginByPassword", "wechatScanQr", "wechatScanPoll"}) {
+            for (Method method : AdminAuthController.class.getDeclaredMethods()) {
+                if (method.getName().equals(m)) {
+                    assertNotNull(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class),
+                            "AdminAuthController." + m + " 必须为 @PostMapping");
+                }
+            }
+        }
+    }
+}

+ 246 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG04InviteRegisterContractTest.java

@@ -0,0 +1,246 @@
+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.controller.auth.AuthController;
+import com.etotem.cfc.controller.family.FamilyInviteController;
+import com.etotem.cfc.dto.InviteValidateDTO;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyInvitationService;
+import com.etotem.cfc.service.FamilyJoinRequestService;
+import com.etotem.cfc.service.QrCodeService;
+import com.etotem.cfc.service.UnlockGateService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.VerificationCodeService;
+import com.etotem.cfc.service.WechatService;
+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.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * SC-G-04 邀请码注册加入家庭 契约级单测(能力键 family.invite.register)
+ * ================================================================
+ * 覆盖:
+ * - 正常流 (TC-G-04):POST /api/auth/register-with-invite 携带有效邀请令牌 + 正确验证码
+ *   → 注册成功并返回 token + 归属家庭(userService.registerWithInviteCode 被调用)。
+ * - 异常流 (TC-G-04-E):邀请码失效/验证码错误/手机号已注册 → 明确拒绝(不串家庭);
+ *   邀请令牌为空 → 提示「邀请令牌不能为空」。
+ * - 权限流 (TC-G-04-P):register-with-invite / invite/validate / validate-family-code 在公开白名单;
+ *   /api/family/invite/generate 需登录(游客 401)。
+ *
+ * 溯源:SC-G-04 · tests/user-scenarios/01-guest.md 场景 G-4 · tests/test-cases/guest.md TC-G-04/TC-G-04-E/TC-G-04-P
+ *
+ * 纯单元测试:Mockito 直接调用 handler,不依赖数据库/真实微信/Spring 容器。
+ */
+public class ScG04InviteRegisterContractTest {
+
+    @Mock
+    private UserService userService;
+    @Mock
+    private VerificationCodeService verificationCodeService;
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private AuthController authController;
+
+    @Mock
+    private FamilyMapper familyMapper;
+    @Mock
+    private QrCodeService qrCodeService;
+    @Mock
+    private FamilyInvitationService familyInvitationService;
+    @Mock
+    private FamilyJoinRequestService familyJoinRequestService;
+    @Mock
+    private WechatService wechatService;
+    @Mock
+    private UnlockGateService unlockGateService;
+    @InjectMocks
+    private FamilyInviteController familyInviteController;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-04:register-with-invite 有效令牌+验证码 → 注册成功归属家庭
+    // ====================================================================
+
+    @Test
+    public void registerWithInvite_ValidInput_ReturnsTokenAndFamily() {
+        RegisterWithInviteCodeDTO dto = new RegisterWithInviteCodeDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("123456");
+        dto.setInviteCode("INVITE-ABC-123");
+        dto.setRole("parent");
+        dto.setNickname("新用户");
+        dto.setCodeId("code-id-1");
+
+        when(verificationCodeService.verifyCode("13800000001", "123456", "code-id-1")).thenReturn(true);
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(false);
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setToken("invite-jwt");
+        login.setUserId(101L);
+        login.setRole("parent");
+        login.setFamilyId(202L);
+        login.setIsNewUser(true);
+        when(userService.registerWithInviteCode(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = authController.registerWithInvite(dto);
+
+        assertEquals(200, result.getCode(), "邀请注册成功应返回 code=200");
+        assertEquals("invite-jwt", result.getData().getToken());
+        assertEquals(202L, result.getData().getFamilyId(), "注册后应归属对应家庭");
+        assertTrue(result.getData().getIsNewUser());
+        verify(userService, times(1)).registerWithInviteCode(dto);
+    }
+
+    @Test
+    public void validateInvite_ValidToken_ReturnsFamilyInfo() {
+        Map<String, String> body = new HashMap<>();
+        body.put("token", "VALID-TOKEN");
+
+        InviteValidateDTO dto = new InviteValidateDTO();
+        dto.setFamilyId(202L);
+        dto.setFamilyName("测试家庭");
+        dto.setInviterName("家长");
+        dto.setMemberCount(3);
+        when(familyInvitationService.validateInvitation("VALID-TOKEN")).thenReturn(dto);
+
+        Result<InviteValidateDTO> result = familyInviteController.validateInvite(body);
+
+        assertEquals(200, result.getCode());
+        assertEquals("测试家庭", result.getData().getFamilyName());
+        assertEquals(202L, result.getData().getFamilyId());
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-04-E:令牌失效/验证码错/已注册 → 明确拒绝
+    // ====================================================================
+
+    @Test
+    public void registerWithInvite_InvalidCode_ReturnsErrorNoSession() {
+        RegisterWithInviteCodeDTO dto = new RegisterWithInviteCodeDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("wrong");
+        dto.setInviteCode("INVITE-ABC-123");
+        dto.setCodeId("code-id-1");
+
+        when(verificationCodeService.verifyCode(anyString(), anyString(), anyString())).thenReturn(false);
+
+        Result<LoginResultDTO> result = authController.registerWithInvite(dto);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("验证码错误"), "实际: " + result.getMessage());
+        assertNull(result.getData(), "验证码错误不产生会话");
+        verify(userService, never()).registerWithInviteCode(any());
+    }
+
+    @Test
+    public void registerWithInvite_AlreadyRegistered_ReturnsError() {
+        RegisterWithInviteCodeDTO dto = new RegisterWithInviteCodeDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("123456");
+        dto.setInviteCode("INVITE-ABC-123");
+        dto.setCodeId("code-id-1");
+
+        when(verificationCodeService.verifyCode(anyString(), anyString(), anyString())).thenReturn(true);
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(true);
+
+        Result<LoginResultDTO> result = authController.registerWithInvite(dto);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("已注册"), "实际: " + result.getMessage());
+        verify(userService, never()).registerWithInviteCode(any());
+    }
+
+    @Test
+    public void registerWithInvite_MissingInviteCode_ReturnsError() {
+        RegisterWithInviteCodeDTO dto = new RegisterWithInviteCodeDTO();
+        dto.setPhone("13800000001");
+        dto.setCode("123456");
+        dto.setInviteCode(null);
+
+        Result<LoginResultDTO> result = authController.registerWithInvite(dto);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("邀请码不能为空"), "实际: " + result.getMessage());
+    }
+
+    @Test
+    public void registerWithInvite_MissingPhone_ReturnsError() {
+        RegisterWithInviteCodeDTO dto = new RegisterWithInviteCodeDTO();
+        dto.setPhone(null);
+        dto.setCode("123456");
+        dto.setInviteCode("INVITE-ABC-123");
+        Result<LoginResultDTO> result = authController.registerWithInvite(dto);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("手机号不能为空"), "实际: " + result.getMessage());
+    }
+
+    @Test
+    public void validateInvite_EmptyToken_ReturnsError() {
+        Map<String, String> body = new HashMap<>();
+        body.put("token", "");
+        Result<InviteValidateDTO> result = familyInviteController.validateInvite(body);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("邀请令牌不能为空"), "实际: " + result.getMessage());
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-04-P:register/invite-validate 公开;invite/generate 需登录
+    // ====================================================================
+
+    @Test
+    public void permission_publicInviteEndpoints_anonymousAllowed() throws Exception {
+        String[] publicPaths = {
+                "/api/auth/register-with-invite",
+                "/api/family/invite/validate",
+                "/api/family/invite/validate-family-code",
+        };
+        for (String path : publicPaths) {
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setMethod("POST");
+            request.setRequestURI(path);
+            MockHttpServletResponse response = new MockHttpServletResponse();
+            boolean allowed = jwtInterceptor.preHandle(request, response, null);
+            assertTrue(allowed, "邀请相关公开端点游客应可触达: " + path);
+            assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+        }
+    }
+
+    @Test
+    public void permission_generateInvite_requiresLogin() {
+        // 无 token 调用 generate(通过 JwtInterceptor 判定 /api/family/invite/generate 不在公开白名单,
+        // 且 generateInvite 控制器内 userId==null → 返回「请先登录」)
+        Result<com.etotem.cfc.dto.InviteGenerateDTO> result = familyInviteController.generateInvite(null);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("请先登录"), "实际: " + result.getMessage());
+    }
+}

+ 193 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG05AnonymousBrowseContractTest.java

@@ -0,0 +1,193 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.controller.content.ArticleController;
+import com.etotem.cfc.controller.product.ProductController;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import io.jsonwebtoken.Claims;
+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 org.springframework.web.bind.annotation.PostMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * SC-G-05 未登录访问业务页被拦截;浏览商城/文章可被放行 契约级单测(能力键 auth.guest.browse)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-5、tests/test-cases/guest.md TC-G-05/TC-G-05-E):
+ * - 正常流 (TC-G-05):游客无 token 可匿名浏览 `/api/product/list`、`/api/articles/list`(公开白名单);
+ *   两个列表端点结构契约均为 @PostMapping(统一 POST)。
+ * - 异常流 (TC-G-05-E):未登录直接访问受保护业务接口(家庭成员列表)→ 401;公开路径携带无效/过期 token
+ *   → 降级匿名放行、不误拦 401(保持游客可浏览)。
+ * - 权限流 (TC-G-05-P):登录用户访问公开路径享受登录上下文(X-User-Id 注入,不误拦);未登录受保护接口
+ *   统一 401 且响应体不泄露内部异常/堆栈。
+ *
+ * 溯源:SC-G-05 · tests/user-scenarios/01-guest.md 场景 G-5 · tests/test-cases/guest.md TC-G-05/TC-G-05-E/
+ *
+ * 纯单元测试:Mockito + MockHttpServletRequest/Response(权限流走真实 JwtInterceptor 契约),
+ * 不依赖数据库 / 真实微信 / Spring 容器。
+ */
+public class ScG05AnonymousBrowseContractTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-05:公开浏览路径匿名放行
+    // ====================================================================
+
+    @Test
+    public void normal_publicProductList_anonymousAllowed() throws Exception {
+        assertPublicAllowed("/api/product/list");
+    }
+
+    @Test
+    public void normal_publicArticlesList_anonymousAllowed() throws Exception {
+        assertPublicAllowed("/api/articles/list");
+    }
+
+    @Test
+    public void normal_productList_isPostMapping() {
+        assertPostMapping(ProductController.class, "list");
+    }
+
+    @Test
+    public void normal_articlesList_isPostMapping() {
+        assertPostMapping(ArticleController.class, "list");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-05-E:未登录受保护接口 401;公开路径无效 token 降级匿名放行
+    // ====================================================================
+
+    @Test
+    public void exception_protectedFamilyMemberList_noToken_401() throws Exception {
+        assertProtected401("/api/family/member/list");
+    }
+
+    @Test
+    public void exception_publicPath_invalidToken_degradedAnonymous() throws Exception {
+        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 应降级匿名放行,不误拦 401");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "公开路径不应写 401");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-05-P:登录用户公开路径享受上下文;未登录业务接口统一 401 不泄露
+    // ====================================================================
+
+    @Test
+    public void permission_loggedInUser_onPublicPath_getsUserIdContext() throws Exception {
+        Claims claims = mock(Claims.class);
+        when(jwtConfig.parseToken(anyString())).thenReturn(claims);
+        when(claims.getSubject()).thenReturn("100");
+        when(claims.get("role")).thenReturn("parent");
+        when(claims.get("roles")).thenReturn(null);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/product/list");
+        request.addHeader("Authorization", "Bearer valid.token");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "登录用户访问公开路径应放行(不误拦)");
+        assertEquals("100", response.getHeader("X-User-Id"), "公开路径应注入登录用户上下文");
+        assertEquals(Long.valueOf(100L), (Long) request.getAttribute("userId"));
+    }
+
+    @Test
+    public void permission_protected_noToken_401_unifiedResult() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "受保护接口无 token 必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), "应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), "应含统一 code=401,实际: " + body);
+        assertTrue(body.contains("请先登录"), "应含统一 message,实际: " + body);
+        assertFalse(body.contains("Exception"), "不得泄露内部异常: " + body);
+        assertFalse(body.contains("at com.etotem"), "不得泄露调用堆栈: " + body);
+    }
+
+    // ====================================================================
+    // 断言辅助
+    // ====================================================================
+
+    private void assertPublicAllowed(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");
+    }
+
+    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 + " 应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), path + " 应含统一 code=401,实际: " + body);
+        assertFalse(body.contains("Exception"), path + " 不得泄露内部异常: " + body);
+    }
+
+    private void assertPostMapping(Class<?> controllerClass, String methodName) {
+        boolean found = false;
+        for (Method method : controllerClass.getDeclaredMethods()) {
+            if (method.getName().equals(methodName)) {
+                found = true;
+                assertNotNull(method.getAnnotation(PostMapping.class),
+                        controllerClass.getSimpleName() + "." + methodName + " 必须为 @PostMapping(统一 POST 规范)");
+            }
+        }
+        assertTrue(found, controllerClass.getSimpleName() + "." + methodName + " 方法必须存在");
+    }
+}

+ 95 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG07TestModeContractTest.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.service.WechatService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-07 微信测试模式跳过授权 契约级单测(能力键 auth.guest.testMode)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-7、tests/test-cases/guest.md TC-G-07/TC-G-07-E/TC-G-07-P):
+ * - 正常流 (TC-G-07):测试模式开 → code2Session 返回模拟 openid(test_openid_ 前缀),不触网、不依赖真实微信。
+ * - 异常流 (TC-G-07-E):生产环境误开 test-mode → 启动即抛 IllegalStateException(生产强制真实微信授权)。
+ * - 权限流 (TC-G-07-P):test-mode 关闭时 isTestMode()=false;生产环境未开 test-mode 正常启动;
+ *   非生产环境开启 test-mode 仅告警、不崩溃(测试模式仅测试环境生效)。
+ *
+ * 溯源:SC-G-07 · tests/user-scenarios/01-guest.md 场景 G-7 · tests/test-cases/guest.md TC-G-07/TC-G-07-E/TC-G-07-P
+ *
+ * 纯单元测试:直接 new WechatService() + 反射注入 @Value 字段(testMode/activeProfile),
+ * 不依赖数据库 / 真实微信网络 / Spring 容器,保证确定性。
+ */
+public class ScG07TestModeContractTest {
+
+    /**
+     * 构造测试模式可控的 WechatService(反射注入 @Value 私有字段)。
+     */
+    private WechatService newService(boolean testMode, String activeProfile) {
+        WechatService service = new WechatService();
+        ReflectionTestUtils.setField(service, "testMode", testMode);
+        ReflectionTestUtils.setField(service, "activeProfile", activeProfile);
+        return service;
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-07:测试模式开 → code2Session 返回模拟 openid,不触网
+    // ====================================================================
+
+    @Test
+    public void normal_testMode_code2Session_returnsMockOpenid() {
+        WechatService service = newService(true, "dev");
+
+        assertTrue(service.isTestMode(), "测试模式开启后 isTestMode 应为 true");
+        Map<String, String> result = service.code2Session("wx-code");
+
+        assertNotNull(result, "测试模式应返回非空会话结果");
+        assertNotNull(result.get("openid"), "应返回模拟 openid");
+        assertTrue(result.get("openid").startsWith("test_openid_"),
+                "模拟 openid 应以 test_openid_ 前缀标识,实际: " + result.get("openid"));
+        assertEquals("test_session_key", result.get("session_key"), "应返回模拟 session_key");
+        assertEquals("", result.get("unionid"), "模拟 unionid 应为空串");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-07-E:生产环境误开 test-mode → 启动拒绝
+    // ====================================================================
+
+    @Test
+    public void exception_prodProfile_withTestMode_throwsAtStartup() {
+        WechatService service = newService(true, "prod");
+
+        assertThrows(IllegalStateException.class, service::checkTestModeAtStartup,
+                "生产环境开启测试模式必须拒绝启动(强制真实微信授权)");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-07-P:测试模式仅在非生产生效;生产未开正常启动
+    // ====================================================================
+
+    @Test
+    public void permission_prodProfile_withoutTestMode_startsNormally() {
+        WechatService service = newService(false, "prod");
+
+        assertFalse(service.isTestMode(), "生产环境未开测试模式");
+        assertDoesNotThrow(service::checkTestModeAtStartup,
+                "生产环境未开测试模式应正常启动");
+    }
+
+    @Test
+    public void permission_nonProdProfile_withTestMode_warnsButDoesNotThrow() {
+        WechatService service = newService(true, "dev");
+
+        assertDoesNotThrow(service::checkTestModeAtStartup,
+                "非生产环境开启测试模式仅告警,不应崩溃");
+    }
+
+    @Test
+    public void permission_testModeOff_code2SessionNotMocked() {
+        // 测试模式关闭:isTestMode=false,走真实微信授权分支(不返回 mock openid 前缀)
+        WechatService service = newService(false, "dev");
+        assertFalse(service.isTestMode(), "测试模式关闭时不应返回模拟数据");
+    }
+}

+ 82 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG08EnvAdaptiveContractTest.java

@@ -0,0 +1,82 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.testutil.FrontendSourceLocator;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-08 环境自适应切换 契约级单测(能力键 env.adaptive.switch)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-8、tests/test-cases/guest.md TC-G-08/TC-G-08-E):
+ * - 正常流 (TC-G-08):启动时经 uni.getAccountInfoSync().miniProgram.envVersion 判定环境;
+ *   develop/trial/release 三环境映射到对应 API 地址(当前实现统一 https://cfc.etotem.com.cn)。
+ * - 异常流 (TC-G-08-E):环境识别失败(getAccountInfoSync 异常/空)→ try/catch 回退默认地址,功能可用。
+ * - 权限流 (TC-G-08-P):api.js 统一从 config.js 取 API_BASE_URL(单一地址源,禁止页面内联硬编码导致错连)。
+ *
+ * 溯源:SC-G-08 · tests/user-scenarios/01-guest.md 场景 G-8 · tests/test-cases/guest.md TC-G-08/TC-G-08-E
+ *
+ * 前端源契约:读取 cfc-frontend/config.js 与 utils/api.js 源码做静态断言(不依赖运行时/微信环境)。
+ */
+public class ScG08EnvAdaptiveContractTest {
+
+    private String config() {
+        return FrontendSourceLocator.readFrontendSource("config.js");
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-08:环境判定 + 三环境映射 + 默认地址
+    // ====================================================================
+
+    @Test
+    public void normal_envDetection_usesGetAccountInfoSyncEnvVersion() {
+        String c = config();
+        assertTrue(c.contains("uni.getAccountInfoSync()"), "环境判定必须调用 uni.getAccountInfoSync()");
+        assertTrue(c.contains("envVersion"), "必须读取 miniProgram.envVersion 判定当前环境");
+    }
+
+    @Test
+    public void normal_threeEnv_mappedToConfiguredApiBase() {
+        String c = config();
+        assertTrue(c.contains("case 'develop'"), "应配置 develop 环境分支");
+        assertTrue(c.contains("case 'trial'"), "应配置 trial 环境分支");
+        assertTrue(c.contains("case 'release'"), "应配置 release 环境分支");
+        assertTrue(c.contains("https://cfc.etotem.com.cn"), "应配置线上统一 API 地址(当前三环境同址)");
+    }
+
+    @Test
+    public void normal_defaultBaseFixed_andExported() {
+        String c = config();
+        assertTrue(c.contains("API_BASE_URL"), "config.js 必须定义 API_BASE_URL");
+        assertTrue(c.contains("http://cfc.iwintrue.com"), "应内置默认(兜底)地址 http://cfc.iwintrue.com");
+        assertTrue(c.contains("export default"), "config.js 必须默认导出配置对象");
+        assertTrue(c.contains("api(path)"), "应导出 api(path) 拼接方法供请求层使用");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-08-E:环境识别失败 → 回退默认地址,不崩溃
+    // ====================================================================
+
+    @Test
+    public void exception_envDetectionFailure_fallsBackToDefault() {
+        String c = config();
+        assertTrue(c.contains("try {"), "环境探测必须包裹在 try 内");
+        assertTrue(c.contains("catch"), "环境探测失败必须被 catch 捕获(弱网/非微信环境)");
+        assertTrue(c.contains("默认地址"), "catch 分支必须回退默认地址,避免白屏/错连");
+        assertTrue(c.contains("API_BASE_URL = 'http://cfc.iwintrue.com'"),
+                "默认地址必须存在(回退后仍可请求)");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-08-P:统一 API 地址源(防页面内联错连环境)
+    // ====================================================================
+
+    @Test
+    public void permission_apiRequests_useUnifiedConfigBase() {
+        String api = FrontendSourceLocator.readFrontendSource("utils/api.js");
+        assertTrue(api.contains("import config from '@/config.js'"),
+                "api.js 必须统一引用 config.js 作为地址源");
+        assertTrue(api.contains("config.API_BASE_URL"),
+                "请求 base 必须来自 config 统一配置,禁止页面内联硬编码 API 地址(防跨环境错连)");
+    }
+}

+ 131 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG09LogoutContractTest.java

@@ -0,0 +1,131 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.testutil.FrontendSourceLocator;
+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 static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-09 退出登录 契约级单测(能力键 auth.guest.logout)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-9、tests/test-cases/guest.md TC-G-09/TC-G-09-E/TC-G-09-P):
+ * - 正常流 (TC-G-09):个人中心「退出登录」入口 → 确认后提交 store logout mutation → 清除登录态并回首页。
+ * - 异常流 (TC-G-09-E):退出/会话过期后本地敏感缓存(token/userId/openid/userInfo/currentChildId 等)清除;
+ *   退出后(无 token)访问受保护接口 → 统一 401。
+ * - 权限流 (TC-G-09-P):401 分流统一走 _clearAuthAndRedirect 清登录态并跳转登录页;受保护接口 401 响应
+ *   不泄露内部异常/堆栈。
+ *
+ * 溯源:SC-G-09 · tests/user-scenarios/01-guest.md 场景 G-9 · tests/test-cases/guest.md TC-G-09/TC-G-09-E/TC-G-09-P
+ *
+ * 前端源契约(FrontendSourceLocator 读 cfc-frontend 源码做静态断言)+ JwtInterceptor 契约(Mockito,不依赖 DB/容器)。
+ */
+public class ScG09LogoutContractTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-09:个人中心退出登录 → 清登录态 + 回首页
+    // ====================================================================
+
+    @Test
+    public void normal_profileLogoutEntry_clearsAuthAndRedirects() {
+        String profileMenu = FrontendSourceLocator.readFrontendSource("pages/profile/components/ProfileMenu.vue");
+        assertTrue(profileMenu.contains("退出登录"), "个人中心必须提供退出登录入口");
+        assertTrue(profileMenu.contains("this.$store.commit('logout')"), "退出确认后必须提交 logout mutation");
+        assertTrue(profileMenu.contains("uni.reLaunch({ url: '/pages/index-home/index' })"),
+                "退出后应回到首页/引导页");
+    }
+
+    @Test
+    public void normal_storeLogoutMutation_clearsLoginKeys() {
+        String store = FrontendSourceLocator.readFrontendSource("store/index.js");
+        assertTrue(store.contains("logout(state)"), "store 必须定义 logout mutation");
+        for (String key : new String[]{"token", "userId", "role", "openid", "userInfo", "currentChildId"}) {
+            assertTrue(store.contains("uni.removeStorageSync('" + key + "')"),
+                    "logout mutation 必须清除本地登录态: " + key);
+        }
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-09-E:敏感缓存清除;退出后受保护接口 401
+    // ====================================================================
+
+    @Test
+    public void exception_logout_clearsSensitiveLocalCache() {
+        String api = FrontendSourceLocator.readFrontendSource("utils/api.js");
+        assertTrue(api.contains("_clearAuthAndRedirect"), "401/退出必须走统一清理登录态逻辑");
+        for (String key : new String[]{"token", "userId", "currentRole", "role", "nickname", "userName", "currentChildId"}) {
+            assertTrue(api.contains("removeStorageSync('" + key + "')"),
+                    "会话清理必须清除本地敏感缓存: " + key);
+        }
+    }
+
+    @Test
+    public void exception_afterLogout_noToken_protectedIs401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "退出后无 token 访问受保护接口必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), "退出后应返回 401 需重新登录");
+        assertTrue(response.getContentAsString().contains("\"code\":401"), "应返回统一 code=401");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-09-P:401 分流统一清理;异常不泄露
+    // ====================================================================
+
+    @Test
+    public void permission_401Handling_clearsAndRedirectsToLogin() {
+        String api = FrontendSourceLocator.readFrontendSource("utils/api.js");
+        assertTrue(api.contains("statusCode === 401 || res.data.code === 401"),
+                "请求层应统一识别 HTTP 401 与业务 code=401");
+        assertTrue(api.contains("uni.removeStorageSync('token')"), "401 处理必须先清 token");
+        assertTrue(api.contains("uni.reLaunch({ url: '/pages/login/login' })"),
+                "非启动窗口的 401 应跳转登录页");
+    }
+
+    @Test
+    public void permission_noToken_protected_unified401_noStackLeak() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/tasks/today");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "受保护接口无 token 必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), "应含统一 code=401,实际: " + body);
+        assertFalse(body.contains("Exception"), "不得泄露内部异常: " + body);
+        assertFalse(body.contains("at com.etotem"), "不得泄露调用堆栈: " + body);
+    }
+}

+ 174 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG10RegisterGuideContractTest.java

@@ -0,0 +1,174 @@
+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.controller.auth.AuthController;
+import com.etotem.cfc.dto.CheckPhoneDTO;
+import com.etotem.cfc.dto.DirectRegisterDTO;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.SsoService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.VerificationCodeService;
+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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * SC-G-10 账号不存在引导注册 契约级单测(能力键 auth.guest.registerGuide)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-10、tests/test-cases/guest.md TC-G-10/TC-G-10-E/TC-G-10-P):
+ * - 正常流 (TC-G-10):check-phone 返回未注册(false)→ 引导注册;direct-register 未注册 → 建档成功返回 token。
+ * - 异常流 (TC-G-10-E):已注册手机号 check-phone=true、direct-register 拒绝「已注册请用验证码登录」(不重复建档);
+ *   手机号格式非法 → 明确提示,不建档。
+ * - 权限流 (TC-G-10-P):check-phone / direct-register 端点在公开白名单,游客无 token 可触达(不误拦 401)。
+ *
+ * 溯源:SC-G-10 · tests/user-scenarios/01-guest.md 场景 G-10 · tests/test-cases/guest.md TC-G-10/TC-G-10-E/TC-G-10-P
+ *
+ * 纯单元测试:Mockito 直接调用 handler + MockHttpServletRequest/Response,不依赖数据库/真实微信/Spring 容器。
+ */
+public class ScG10RegisterGuideContractTest {
+
+    @Mock
+    private UserService userService;
+    @Mock
+    private VerificationCodeService verificationCodeService;
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private SsoService ssoService;
+    @InjectMocks
+    private AuthController authController;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-10:未注册 → 引导注册;direct-register 建档成功
+    // ====================================================================
+
+    @Test
+    public void normal_checkPhone_unregistered_returnsFalse_guideRegister() {
+        CheckPhoneDTO dto = new CheckPhoneDTO();
+        dto.setPhone("13800000001");
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(false);
+
+        Result<Boolean> result = authController.checkPhone(dto);
+
+        assertEquals(200, result.getCode(), "check-phone 成功应返回 code=200");
+        assertFalse(result.getData(), "未注册手机号应返回 false,引导进入注册流程");
+    }
+
+    @Test
+    public void normal_directRegister_unregistered_createsAccount() {
+        DirectRegisterDTO dto = new DirectRegisterDTO();
+        dto.setPhone("13800000001");
+        dto.setNickname("新用户");
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(false);
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setToken("reg-jwt");
+        login.setUserId(55L);
+        login.setRole("parent");
+        login.setIsNewUser(true);
+        when(userService.directRegister(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = authController.directRegister(dto);
+
+        assertEquals(200, result.getCode(), "未注册建档成功应返回 code=200");
+        assertEquals("reg-jwt", result.getData().getToken(), "应下发登录 token");
+        assertTrue(result.getData().getIsNewUser(), "应标记为新用户");
+        verify(userService, times(1)).directRegister(dto);
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-10-E:已注册不重复建档;格式非法明确提示
+    // ====================================================================
+
+    @Test
+    public void exception_checkPhone_registered_returnsTrue() {
+        CheckPhoneDTO dto = new CheckPhoneDTO();
+        dto.setPhone("13800000001");
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(true);
+
+        Result<Boolean> result = authController.checkPhone(dto);
+
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData(), "已注册手机号应返回 true(引导直接登录)");
+    }
+
+    @Test
+    public void exception_directRegister_alreadyRegistered_rejectedNoRebuild() {
+        DirectRegisterDTO dto = new DirectRegisterDTO();
+        dto.setPhone("13800000001");
+        when(userService.isPhoneRegistered("13800000001")).thenReturn(true);
+
+        Result<LoginResultDTO> result = authController.directRegister(dto);
+
+        assertEquals(500, result.getCode(), "已注册应拒绝建档");
+        assertTrue(result.getMessage().contains("已注册"), "应提示已注册请用验证码登录,实际: " + result.getMessage());
+        assertNull(result.getData(), "已注册不产生新会话");
+        verify(userService, never()).directRegister(any());
+    }
+
+    @Test
+    public void exception_directRegister_invalidPhoneFormat_returnsError() {
+        DirectRegisterDTO dto = new DirectRegisterDTO();
+        dto.setPhone("123");
+
+        Result<LoginResultDTO> result = authController.directRegister(dto);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("格式不正确"), "手机号格式非法应提示,实际: " + result.getMessage());
+        verify(userService, never()).directRegister(any());
+    }
+
+    @Test
+    public void exception_checkPhone_emptyPhone_returnsError() {
+        CheckPhoneDTO dto = new CheckPhoneDTO();
+        dto.setPhone("");
+
+        Result<Boolean> result = authController.checkPhone(dto);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("不能为空"), "空手机号应提示,实际: " + result.getMessage());
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-10-P:注册引导端点公开(游客无 token 可触达)
+    // ====================================================================
+
+    @Test
+    public void permission_registerGuideEndpoints_publicNotBlocked() throws Exception {
+        String[] publicPaths = {"/api/auth/check-phone", "/api/auth/direct-register"};
+        for (String path : publicPaths) {
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setMethod("POST");
+            request.setRequestURI(path);
+            MockHttpServletResponse response = new MockHttpServletResponse();
+
+            boolean allowed = jwtInterceptor.preHandle(request, response, null);
+
+            assertTrue(allowed, "注册引导端点应为公开路径,游客可触达: " + path);
+            assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "注册引导端点不应误拦 401: " + path);
+        }
+    }
+}

+ 118 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG11AgreementContractTest.java

@@ -0,0 +1,118 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.testutil.FrontendSourceLocator;
+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 static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-11 隐私政策 / 用户协议查看 契约级单测(能力键 policy.agreement.view)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-11、tests/test-cases/guest.md TC-G-11/TC-G-11-E/TC-G-11-P):
+ * - 正常流 (TC-G-11):用户协议页与隐私政策页存在,可查看全文;协议含平台名称、运营主体与微信授权注册说明
+ *   (合规告知)。
+ * - 异常流 (TC-G-11-E):协议内容为静态内置文本、不依赖网络请求加载(不因弱网白屏);未同意/未登录不可
+ *   绕过鉴权进入核心流程。
+ * - 权限流 (TC-G-11-P):未登录(未同意前)直调受保护业务接口(家庭成员/五维能量)→ 统一 401,不泄露内部
+ *   错误与结构。
+ *
+ * 溯源:SC-G-11 · tests/user-scenarios/01-guest.md 场景 G-11 · tests/test-cases/guest.md TC-G-11/TC-G-11-E/TC-G-11-P
+ *
+ * 前端源契约(FrontendSourceLocator 读 cfc-frontend 源码做静态断言)+ JwtInterceptor 契约(Mockito,不依赖 DB/容器)。
+ */
+public class ScG11AgreementContractTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-11:协议/隐私政策页存在且可查看全文
+    // ====================================================================
+
+    @Test
+    public void normal_agreementAndPrivacyPagesExist() {
+        assertTrue(FrontendSourceLocator.frontendSourceExists("pages/policy/agreement.vue"),
+                "用户协议页 pages/policy/agreement.vue 必须存在");
+        assertTrue(FrontendSourceLocator.frontendSourceExists("pages/policy/privacy.vue"),
+                "隐私政策页 pages/policy/privacy.vue 必须存在");
+    }
+
+    @Test
+    public void normal_agreementContent_complianceInfo() {
+        String a = FrontendSourceLocator.readFrontendSource("pages/policy/agreement.vue");
+        assertTrue(a.contains("用户协议"), "协议页应展示「用户协议」标题");
+        assertTrue(a.contains("浠艾福"), "协议应含平台名称(浠艾福)");
+        assertTrue(a.contains("亿图腾"), "协议应含运营主体(亿图腾)");
+        assertTrue(a.contains("微信"), "协议应说明微信授权登录注册方式");
+        assertTrue(a.contains("同意"), "协议应包含同意/确认条款");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-11-E:静态内置内容(不依赖网络,防白屏)
+    // ====================================================================
+
+    @Test
+    public void exception_agreementContent_isStaticNoNetworkDependency() {
+        String a = FrontendSourceLocator.readFrontendSource("pages/policy/agreement.vue");
+        assertTrue(a.contains("policy-section"), "协议内容应内联为静态条款块");
+        assertFalse(a.contains("uni.request"), "协议内容不应依赖 uni.request 远程加载(内联静态,防白屏)");
+        assertFalse(a.contains("wx.request"), "协议内容不应依赖 wx.request 远程加载");
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-11-P:未同意/未登录不得绕过鉴权进入核心流程
+    // ====================================================================
+
+    @Test
+    public void permission_unapprovedGuest_protectedApi_401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "未登录直调家庭成员接口必须被拦截");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), "应返回 401");
+        assertTrue(response.getContentAsString().contains("\"code\":401"), "应返回统一 code=401");
+    }
+
+    @Test
+    public void permission_guest_protectedApi_noStackLeak() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/energy/overview");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result);
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), "应含统一 code=401,实际: " + body);
+        assertFalse(body.contains("Exception"), "不得泄露内部异常: " + body);
+        assertFalse(body.contains("at com.etotem"), "不得泄露调用堆栈: " + body);
+    }
+}

+ 126 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ScG12H5FallbackContractTest.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.testutil.FrontendSourceLocator;
+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 static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * SC-G-12 非微信环境 H5 兜底 契约级单测(能力键 env.h5Fallback)
+ * ================================================================
+ * 覆盖(对应 tests/user-scenarios/01-guest.md 场景 G-12、tests/test-cases/guest.md TC-G-12/TC-G-12-E/TC-G-12-P):
+ * - 正常流 (TC-G-12):非微信环境(H5/dev)→ 环境探测走 catch 回退默认地址(http://cfc.iwintrue.com),
+ *   API_BASE_URL 仍导出供请求层使用,功能可用。
+ * - 异常流 (TC-G-12-E):环境探测(uni.getAccountInfoSync)包裹在 try 内,异常被 catch 捕获不崩溃;
+ *   H5 降级仍可匿名浏览公开内容(商品/文章),不因无微信 SDK 误拦。
+ * - 权限流 (TC-G-12-P):H5 兜底不诱导下载/安装;受保护业务接口仍统一 401(不因降级而放开鉴权)。
+ *
+ * 溯源:SC-G-12 · tests/user-scenarios/01-guest.md 场景 G-12 · tests/test-cases/guest.md TC-G-12/TC-G-12-E/TC-G-12-P
+ *
+ * 前端源契约(FrontendSourceLocator 读 cfc-frontend/config.js 源码做静态断言)+ JwtInterceptor 契约(Mockito)。
+ */
+public class ScG12H5FallbackContractTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-12:非微信环境回退默认地址,仍可请求
+    // ====================================================================
+
+    @Test
+    public void normal_nonWechatEnv_fallsBackToDefaultBase() {
+        String c = FrontendSourceLocator.readFrontendSource("config.js");
+        assertTrue(c.contains("catch"), "非微信/H5 环境应走 catch 回退分支");
+        assertTrue(c.contains("默认地址"), "catch 应回退默认地址(不白屏)");
+        assertTrue(c.contains("http://cfc.iwintrue.com"), "H5 默认地址应为 http://cfc.iwintrue.com");
+    }
+
+    @Test
+    public void normal_defaultBase_exportedForApiUse() {
+        String c = FrontendSourceLocator.readFrontendSource("config.js");
+        assertTrue(c.contains("API_BASE_URL"), "应定义并导出 API_BASE_URL");
+        assertTrue(c.contains("export default"), "应默认导出配置对象");
+        assertTrue(c.contains("api(path)"), "应导出 api(path) 供请求层使用");
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-12-E:环境探测异常不崩溃;H5 降级仍可浏览公开内容
+    // ====================================================================
+
+    @Test
+    public void exception_envProbe_wrappedInTryCatch() {
+        String c = FrontendSourceLocator.readFrontendSource("config.js");
+        int tryIdx = c.indexOf("try {");
+        assertTrue(tryIdx >= 0, "config.js 必须包含 try 块");
+        // 注释里也可能出现 uni.getAccountInfoSync()(JSDoc 说明),须锚定 try 之后查找实际调用
+        int probeIdx = c.indexOf("uni.getAccountInfoSync()", tryIdx);
+        assertTrue(probeIdx > tryIdx, "环境探测 uni.getAccountInfoSync() 必须在 try 内(异常不崩溃)");
+        assertTrue(c.contains("catch"), "环境探测失败必须被 catch 捕获");
+    }
+
+    @Test
+    public void exception_h5Fallback_publicBrowseStillAllowed() throws Exception {
+        for (String path : new String[]{"/api/product/list", "/api/articles/list"}) {
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setMethod("POST");
+            request.setRequestURI(path);
+            MockHttpServletResponse response = new MockHttpServletResponse();
+
+            boolean result = jwtInterceptor.preHandle(request, response, null);
+
+            assertTrue(result, "H5 降级仍应可匿名浏览公开内容: " + path);
+            assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "公开内容不应被误拦: " + path);
+        }
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-12-P:不诱导下载/安装;受保护接口仍 401
+    // ====================================================================
+
+    @Test
+    public void permission_h5Fallback_noInstallInducement() {
+        String c = FrontendSourceLocator.readFrontendSource("config.js");
+        assertFalse(c.contains("download"), "H5 兜底不应诱导下载/安装");
+        assertFalse(c.contains("install"), "H5 兜底不应诱导安装");
+    }
+
+    @Test
+    public void permission_h5Fallback_protectedApi_401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "H5 降级不得放开受保护接口鉴权");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), "应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("\"code\":401"), "应返回统一 code=401,实际: " + body);
+        assertFalse(body.contains("Exception"), "不得泄露内部异常: " + body);
+    }
+}

+ 58 - 0
cfc-backend/src/test/java/com/etotem/cfc/testutil/FrontendSourceLocator.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.testutil;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+/**
+ * 前端源码定位工具(SC-G-08 / SC-G-09 / SC-G-11 / SC-G-12 等前端域契约测试用)。
+ *
+ * <p>测试工作目录不固定:Maven 模块 CWD 为 {@code cfc-backend},仓库根 CWD 为 {@code E:/cfc}。
+ * 本工具按候选路径顺序解析 {@code cfc-frontend} 根目录,保证在两种 CWD 下均可找到源文件。
+ */
+public final class FrontendSourceLocator {
+
+    private FrontendSourceLocator() {
+    }
+
+    /** 候选的 cfc-frontend 根目录相对路径(从模块 CWD / 仓库根 CWD 出发)。 */
+    private static final String[] CANDIDATE_ROOTS = {
+            "../cfc-frontend",   // CWD = cfc-backend 模块目录
+            "cfc-frontend",      // CWD = 仓库根
+            "./cfc-frontend",    // CWD = 仓库根(显式)
+    };
+
+    /**
+     * 解析 cfc-frontend 根目录绝对路径,找不到则抛 {@link IllegalStateException}。
+     */
+    public static File frontendRoot() {
+        String userDir = System.getProperty("user.dir", "");
+        for (String candidate : CANDIDATE_ROOTS) {
+            File dir = new File(userDir, candidate).getAbsoluteFile();
+            if (new File(dir, "config.js").isFile()) {
+                return dir;
+            }
+        }
+        throw new IllegalStateException(
+                "无法定位 cfc-frontend 根目录(user.dir=" + userDir + "),请检查测试运行目录");
+    }
+
+    /**
+     * 读取前端源码文件(相对 cfc-frontend 根),返回 UTF-8 内容;不存在则抛异常。
+     */
+    public static String readFrontendSource(String relativePath) {
+        try {
+            File file = new File(frontendRoot(), relativePath);
+            return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new IllegalStateException("读取前端源码失败: " + relativePath, e);
+        }
+    }
+
+    /**
+     * 判断前端源文件是否存在(相对 cfc-frontend 根)。
+     */
+    public static boolean frontendSourceExists(String relativePath) {
+        return new File(frontendRoot(), relativePath).isFile();
+    }
+}