Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 1 неделя назад
Родитель
Сommit
7d0e93c6a6

+ 316 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorContractEdgeTest.java

@@ -0,0 +1,316 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import 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.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-10 · auth.guest.jwtGuard — JwtInterceptor 契约边界补充测试(RED 阶段补齐)。
+ *
+ * <p>与 {@link ScG06JwtGuardTest}(TC-G-06 正常/异常/权限三类主用例)互补,专门覆盖
+ * JwtInterceptor 的关键边界与角色/权限上下文行为:
+ * <ol>
+ *   <li><b>OPTIONS 预检放行</b>:CORS 预检请求不做鉴权,任何路径无 token 直接放行(true),不写 401;</li>
+ *   <li><b>query 参数 token</b>:无 Authorization Header 时允许从 ?token= 读取 token(既有契约);</li>
+ *   <li><b>teacher 有效 token</b>:放行并注入 role=teacher + ROLE_teacher 权限上下文(成长规划师角色适用);</li>
+ *   <li><b>admin 有效 token</b>:放行并注入 role=admin + ROLE_admin 权限上下文(后台角色适用);</li>
+ *   <li><b>LOGIN_REQUIRED_PATHS 边界</b>:无 token 访问 family-role-switch / ai-health-coach → 401;
+ *       携有效 token 访问同一路径 → 放行(有 token 时登录必需路径不再拦截);</li>
+ * </ol>
+ *
+ * 纯单元测试:MockServlet 容器 + Mockito,不依赖数据库/微信/外部服务。
+ */
+public class JwtInterceptorContractEdgeTest {
+
+    @Mock
+    private JwtConfig jwtConfig;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        SecurityContextHolder.clearContext();
+    }
+
+    // ====================================================================
+    // 关键边界 1:OPTIONS 预检请求放行(不鉴权、不写 401)
+    // ====================================================================
+
+    @Test
+    public void optionsPreflight_受保护路径无token_直接放行() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("OPTIONS");
+        request.setRequestURI("/api/family/member/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "OPTIONS 预检必须放行(CORS 前置握手)");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus(), "OPTIONS 不应写 401/403");
+        assertTrue(response.getContentAsString().isEmpty(), "OPTIONS 不应写业务响应体");
+    }
+
+    @Test
+    public void optionsPreflight_公开路径_直接放行() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("OPTIONS");
+        request.setRequestURI("/api/product/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result);
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    // ====================================================================
+    // 关键边界 2:query 参数 token(无 Authorization Header 时从 ?token= 读取)
+    // ====================================================================
+
+    @Test
+    public void queryToken_有效token_放行并注入用户上下文() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/member/list");
+        // 无 Authorization Header,改用 query 参数 token
+        request.setParameter("token", "valid-query-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn("7001");
+        when(claims.get("role")).thenReturn("parent");
+        when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
+        when(jwtConfig.parseToken("valid-query-token")).thenReturn(claims);
+
+        User user = new User();
+        user.setId(7001L);
+        user.setRole("parent");
+        user.setFamilyId(30001L);
+        when(userMapper.selectById(7001L)).thenReturn(user);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(9999L);
+        member.setFamilyId(30001L);
+        member.setUserId(7001L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "query token 有效时应放行");
+        assertEquals(7001L, request.getAttribute("userId"));
+        assertEquals("parent", request.getAttribute("role"));
+        assertEquals(30001L, request.getAttribute("familyId"));
+        assertEquals(9999L, request.getAttribute("currentMemberId"));
+    }
+
+    @Test
+    public void queryToken_无效token_统一401不泄露() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        request.setParameter("token", "bad-query-token");
+
+        when(jwtConfig.parseToken("bad-query-token"))
+                .thenThrow(new RuntimeException("JWT compact of a plaintext string"));
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+        assertFalse(response.getContentAsString().contains("Exception"),
+                "异常信息不得泄露到响应体: " + response.getContentAsString());
+    }
+
+    // ====================================================================
+    // 角色/权限:teacher 有效 token → 放行 + ROLE_teacher 权限上下文
+    // ====================================================================
+
+    @Test
+    public void teacherToken_放行并注入teacher上下文() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        request.addHeader("Authorization", "Bearer teacher-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn("6001");
+        when(claims.get("role")).thenReturn("teacher");
+        when(claims.get("roles")).thenReturn(Collections.singletonList("teacher"));
+        when(jwtConfig.parseToken("teacher-token")).thenReturn(claims);
+
+        User user = new User();
+        user.setId(6001L);
+        user.setRole("teacher");
+        user.setFamilyId(60001L);
+        when(userMapper.selectById(6001L)).thenReturn(user);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(6666L);
+        member.setFamilyId(60001L);
+        member.setUserId(6001L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "teacher 有效 token 应放行");
+        assertEquals(6001L, request.getAttribute("userId"));
+        assertEquals("teacher", request.getAttribute("role"));
+        List<String> roles = (List<String>) request.getAttribute("roles");
+        assertTrue(roles.contains("teacher"), "roles 应含 teacher");
+
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        assertTrue(auth != null && hasAuthority(auth, "ROLE_teacher"),
+                "SecurityContext 应发布 ROLE_teacher 供 @PreAuthorize 使用");
+    }
+
+    // ====================================================================
+    // 角色/权限:admin 有效 token → 放行 + ROLE_admin 权限上下文
+    // ====================================================================
+
+    @Test
+    public void adminToken_放行并注入admin权限上下文() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family-platform/balance");
+        request.addHeader("Authorization", "Bearer admin-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn("8001");
+        when(claims.get("role")).thenReturn("admin");
+        when(claims.get("roles")).thenReturn(Collections.singletonList("admin"));
+        when(jwtConfig.parseToken("admin-token")).thenReturn(claims);
+
+        User user = new User();
+        user.setId(8001L);
+        user.setRole("admin");
+        user.setFamilyId(80001L);
+        when(userMapper.selectById(8001L)).thenReturn(user);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(8008L);
+        member.setFamilyId(80001L);
+        member.setUserId(8001L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "admin 有效 token 应放行");
+        assertEquals(8001L, request.getAttribute("userId"));
+        assertEquals("admin", request.getAttribute("role"));
+
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        assertTrue(auth != null && hasAuthority(auth, "ROLE_admin"),
+                "SecurityContext 应发布 ROLE_admin 供 @PreAuthorize 使用");
+    }
+
+    // ====================================================================
+    // 关键边界 3:LOGIN_REQUIRED_PATHS —— 无 token 401;有效 token 放行
+    // ====================================================================
+
+    @Test
+    public void loginRequiredPath_无token_401() throws Exception {
+        // family-side 角色切换(SEC-20261031-01 强制登录族)
+        assertLoginRequired401("/api/family/user/switch-role");
+    }
+
+    @Test
+    public void loginRequiredAiHealthCoach_无token_401() throws Exception {
+        // AI 健康教练:耗 AI 配额 + 家庭上下文,必须登录
+        assertLoginRequired401("/api/ai/health-coach/send");
+    }
+
+    @Test
+    public void loginRequiredAuthSwitchRole_无token_401() throws Exception {
+        // 角色切换(可重签 token),必须登录
+        assertLoginRequired401("/api/auth/switch-role");
+    }
+
+    @Test
+    public void loginRequiredPath_有效token_放行() throws Exception {
+        // 登录必需路径携有效 token 时不再拦截(401 仅针对无 token/无效 token)
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family/user/switch-role");
+        request.addHeader("Authorization", "Bearer valid-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn("9001");
+        when(claims.get("role")).thenReturn("parent");
+        when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
+        when(jwtConfig.parseToken("valid-token")).thenReturn(claims);
+
+        User user = new User();
+        user.setId(9001L);
+        user.setRole("parent");
+        user.setFamilyId(90001L);
+        when(userMapper.selectById(9001L)).thenReturn(user);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "LOGIN_REQUIRED 路径携有效 token 应放行");
+        assertEquals(9001L, request.getAttribute("userId"));
+    }
+
+    // ====================================================================
+    // 私有 helper
+    // ====================================================================
+
+    private void assertLoginRequired401(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 必须被拦(LOGIN_REQUIRED)");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), path + " 应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("请先登录"), path + " 应返回统一 401 提示: " + body);
+        assertFalse(body.contains("Exception"), path + " 不应泄露内部异常: " + body);
+    }
+
+    private boolean hasAuthority(Authentication auth, String role) {
+        return auth.getAuthorities().stream()
+                .map(GrantedAuthority::getAuthority)
+                .collect(Collectors.toList())
+                .contains(role);
+    }
+}

+ 270 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/AuthWechatLoginContractTest.java

@@ -0,0 +1,270 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.controller.auth.AuthController;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.WechatLoginDTO;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * SC-G-02 微信一键登录 — 契约级单测(能力键 {@code auth.guest.wechatLogin})。
+ *
+ * <p>对应 {@code tests/user-scenarios/01-guest.md} 场景 G-2 与 {@code tests/test-cases/guest.md}:
+ * <ul>
+ *   <li><b>TC-G-02 正常流</b>:{@code POST /api/auth/wechat-login} → {@code Result} 含 token + 用户信息;
+ *       角色默认 {@code parent};首次登录建号({@code isNewUser=true})。</li>
+ *   <li><b>TC-G-02a 边界</b>:同一微信二次登录应返回已有账号,不重复建号({@code isNewUser=false})。</li>
+ *   <li><b>TC-G-02-E 异常流</b>:微信授权失败 / 服务异常 → {@code Result.error}(code=500),
+ *       只透传业务 message,绝不泄露内部堆栈/异常类名。</li>
+ *   <li><b>TC-G-02-P 权限流</b>:登录端点在 {@code JwtInterceptor} 公开白名单内,游客无 token
+ *       可正常触达(匿名放行、不误拦 401)。</li>
+ * </ul>
+ *
+ * <p>纯单元测试:Mockito 直接调用 handler + {@code MockHttpServletRequest/Response},
+ * 不依赖数据库 / 真实微信 / Spring 容器,保证可执行性与确定性。
+ */
+public class AuthWechatLoginContractTest {
+
+    @Mock
+    private UserService userService;
+
+    @InjectMocks
+    private AuthController controller;
+
+    // ---- JwtInterceptor(权限流:公开白名单放行)----
+    @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-02:登录成功 → Result 含 token + 用户信息(角色默认 parent)
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_Success_ReturnsTokenAndUserInfo() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setToken("jwt-token-abc");
+        login.setUserId(88L);
+        login.setRole("parent");
+        login.setFamilyId(1001L);
+        login.setNickname("微信用户");
+        login.setPhone("13800000001");
+        login.setIsNewUser(true);
+        login.setOpenid("openid-abc");
+        login.setNeedsRoleSelect(true);
+        login.setNeedsFamily(false);
+        when(userService.wechatLogin(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        assertEquals(200, result.getCode(), "正常登录应返回 code=200");
+        assertEquals("success", result.getMessage());
+        assertNotNull(result.getData(), "应返回 LoginResultDTO");
+        assertEquals("jwt-token-abc", result.getData().getToken());
+        assertEquals(88L, result.getData().getUserId());
+        assertEquals("parent", result.getData().getRole(), "角色应默认 parent");
+        assertEquals(1001L, result.getData().getFamilyId());
+        assertEquals("微信用户", result.getData().getNickname());
+        assertEquals("13800000001", result.getData().getPhone());
+        assertTrue(result.getData().getIsNewUser(), "首次登录应 isNewUser=true");
+        assertEquals("openid-abc", result.getData().getOpenid());
+        assertTrue(result.getData().getNeedsRoleSelect(), "新用户应 needsRoleSelect=true");
+        assertFalse(result.getData().getNeedsFamily(), "已绑定家庭则 needsFamily=false");
+        verify(userService, times(1)).wechatLogin(dto);
+    }
+
+    @Test
+    public void wechatLogin_NewUser_IsDefaultParentAndNeedsRoleSelect() {
+        // TC-G-02 契约:新用户默认成为家长(role=parent),需选择角色
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setRole("parent");
+        login.setIsNewUser(true);
+        login.setNeedsRoleSelect(true);
+        when(userService.wechatLogin(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        assertEquals(200, result.getCode());
+        assertEquals("parent", result.getData().getRole());
+        assertTrue(result.getData().getIsNewUser());
+        assertTrue(result.getData().getNeedsRoleSelect());
+    }
+
+    // ====================================================================
+    // 边界 TC-G-02a:同一微信二次登录返回已有账号,不重复建号(isNewUser=false)
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_ExistingOpenid_ReturnsExistingAccountNoNewCreation() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        LoginResultDTO login = new LoginResultDTO();
+        login.setUserId(88L);
+        login.setRole("parent");
+        login.setIsNewUser(false);
+        login.setNeedsRoleSelect(false);
+        login.setOpenid("openid-abc");
+        when(userService.wechatLogin(dto)).thenReturn(login);
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        assertEquals(200, result.getCode());
+        assertEquals(88L, result.getData().getUserId(), "应返回已有账号的 userId,不新建");
+        assertFalse(result.getData().getIsNewUser(), "老用户重登 isNewUser 必须为 false");
+        assertFalse(result.getData().getNeedsRoleSelect(), "老用户不再要求选择角色");
+        assertEquals("openid-abc", result.getData().getOpenid());
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-02-E:微信授权失败/服务异常 → Result.error,不泄露内部堆栈
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_ServiceThrowsRuntimeException_ReturnsBusinessError() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("bad-code");
+
+        when(userService.wechatLogin(dto)).thenThrow(new RuntimeException("微信登录失败"));
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        assertEquals(500, result.getCode(), "服务异常应映射为 code=500");
+        assertEquals("微信登录失败", result.getMessage(), "应透传业务 message,不吞掉");
+        assertNull(result.getData(), "异常时不携带业务数据");
+    }
+
+    @Test
+    public void wechatLogin_ServiceThrows_ErrorMessageDoesNotLeakStack() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("bad-code");
+
+        when(userService.wechatLogin(dto))
+                .thenThrow(new RuntimeException("获取openid失败,请重试"));
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        String msg = result.getMessage();
+        assertNotNull(msg);
+        // 契约:错误信息只含业务文案,绝不含异常类名/堆栈帧(不泄露内部结构)
+        assertFalse(msg.contains("RuntimeException"), "不得泄露异常类名: " + msg);
+        assertFalse(msg.contains("at com."), "不得泄露堆栈帧: " + msg);
+        assertFalse(msg.contains("\n"), "错误信息应单行业务文案: " + msg);
+    }
+
+    @Test
+    public void wechatLogin_Code2SessionFailure_ReturnsErrorNoDirtyAccount() {
+        // TC-G-02-E:授权失败 → 返回业务错误,控制器层面不透传 openid/不建号
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("invalid-wx-code");
+
+        when(userService.wechatLogin(dto))
+                .thenThrow(new RuntimeException("获取openid失败,请重试"));
+
+        Result<LoginResultDTO> result = controller.wechatLogin(dto);
+
+        assertEquals(500, result.getCode());
+        assertEquals("获取openid失败,请重试", result.getMessage());
+        assertNull(result.getData());
+        verify(userService, times(1)).wechatLogin(dto);
+    }
+
+    // ====================================================================
+    // 权限流 TC-G-02-P:登录端点在公开白名单内,游客无 token 可放行(不误拦 401)
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_PublicPath_NoToken_AnonymousAllowed() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/auth/wechat-login");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "公开登录端点无 token 必须放行(游客可触达)");
+        assertEquals(200, response.getStatus(), "公开端点不应被写 401");
+    }
+
+    @Test
+    public void wechatLogin_PublicPath_InvalidToken_DowngradedAnonymous() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/auth/wechat-login");
+        request.addHeader("Authorization", "Bearer expired-token");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        // jwtConfig.parseToken 默认返回 null → 解析 NPE → 公开路径降级匿名放行
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "公开端点 token 失效应降级匿名放行,不 401 拦截");
+        assertEquals(200, response.getStatus());
+    }
+
+    @Test
+    public void wechatLogin_IsNotInLoginRequiredPaths() throws Exception {
+        // 契约:/api/auth/wechat-login 属登录入口,不得落入「必须登录」路径族(否则游客被 401)
+        java.lang.reflect.Field field = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        field.setAccessible(true);
+        String[] loginRequired = (String[]) field.get(jwtInterceptor);
+        assertNotNull(loginRequired);
+        for (String p : loginRequired) {
+            assertFalse("/api/auth/wechat-login".equals(p),
+                    "登录入口不应被列入必须登录路径: " + p);
+        }
+    }
+
+    // ====================================================================
+    // 结构契约:端点映射为 POST /api/auth/wechat-login(统一 @PostMapping + 公开)
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_MappedToPostApiAuthWechatLogin() throws Exception {
+        RestController rest = AuthController.class.getAnnotation(RestController.class);
+        assertNotNull(rest, "AuthController 应为 @RestController");
+        RequestMapping classMapping = AuthController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping);
+        assertEquals("/api/auth", classMapping.value()[0], "类映射应为 /api/auth");
+
+        Method m = AuthController.class.getMethod("wechatLogin", WechatLoginDTO.class);
+        PostMapping postMapping = m.getAnnotation(PostMapping.class);
+        assertNotNull(postMapping, "wechatLogin 必须为 @PostMapping(AGENTS.md 禁止 GET/PUT/DELETE)");
+        assertEquals("/wechat-login", postMapping.value()[0]);
+    }
+}

+ 221 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/task/TaskControllerCreateTaskTest.java

@@ -0,0 +1,221 @@
+package com.etotem.cfc.controller.task;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 覆盖驱动(S-cfc-12 / 能力键 task.parent.createTask):
+ * POST /api/tasks/create 创建任务并下发(家长给孩子布置习惯任务)的契约级单测。
+ *
+ * 契约(对应 SC-P-10「创建任务+下发」、API_REFERENCE.md §4.3 任务、TaskController.createTask):
+ * - 正常路径:家长填 标题/成员(B)/积分/频率 → POST /api/tasks/create → 统一 Result&lt;Long&gt; code=200
+ *   data=任务ID;userId(JwtInterceptor 注入)与 DTO 原样转交 taskService.createTask
+ * - DTO 反序列化:title/description/category/memberId/points/duration/taskType/frequency/needReview/
+ *   executorType/dimensionCode 正确映射进 CreateTaskDTO
+ * - 多成员下发边界:body.memberIds=[B1,B2] 原样传 Service(Service 层为每个成员建独立任务实例)
+ * - 统一 @PostMapping:GET 同路径 → 405
+ * - userId 属性缺失(@RequestAttribute required=true):400(该属性由 JwtInterceptor 注入,缺失即未认证)
+ * - Service 拒绝边界:handler 不含 try/catch,业务校验异常(家庭不足2人/成员不属于本家庭/频率或参数非法等)
+ *   直接上抛,由生产 @RestControllerAdvice GlobalExceptionHandler 统一转 Result.error(400, message)
+ *   (与 TC-P-05-E「频率非法 400」口径一致;区别于 addMember 的控制器内 try/catch 契约)
+ *
+ * 角色/权限口径:createTask handler 无 @RequestAttribute("role"/"roles"),角色不在本层判定;
+ * 授权由 JwtInterceptor(有效 token 才注入真实 userId)与 Service 层家庭归属/执行者校验承担,
+ * 故本测试覆盖 userId 注入与传递正确性而非角色分支(teacher|admin 不适用本 handler)。
+ * 缺陷观察(不在此断言为绿灯,交编排侧决策):/api/tasks/create 未列入
+ * JwtInterceptor.LOGIN_REQUIRED_PATHS,匿名(无 token→userId=-1)可到达 handler 走业务 500,
+ * 与 SEC-20261031-01 已修复的"匿名走到 controller 而非安全 401"口径漂移同类。
+ *
+ * 测试隔离:MockMvc standalone + mock TaskService,无 Spring 上下文 / 无 DB / 无外部服务(微信等)。
+ */
+public class TaskControllerCreateTaskTest {
+
+    private static final String URL = "/api/tasks/create";
+
+    @Mock
+    private TaskService taskService;
+
+    private MockMvc mockMvc;
+    private TaskController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new TaskController();
+        // createTask 仅依赖 taskService;其余 @Resource 字段本方法不触碰,保持 null 无碍
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private static final String CHILD_BODY = "{"
+            + "\"title\":\"每天读书30分钟\","
+            + "\"description\":\"培养阅读习惯\","
+            + "\"category\":\"学习类\","
+            + "\"memberId\":11,"
+            + "\"points\":10,"
+            + "\"duration\":30,"
+            + "\"taskType\":\"recurring\","
+            + "\"frequency\":\"daily\","
+            + "\"needReview\":1,"
+            + "\"executorType\":\"child\","
+            + "\"dimensionCode\":\"wisdom\""
+            + "}";
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    public void createTask_success_returnsTaskId() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(42L);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                .andExpect(jsonPath("$.data").value(42));
+
+        verify(taskService).createTask(eq(userId), any(CreateTaskDTO.class));
+    }
+
+    // ==================== DTO 字段映射 ====================
+
+    @Test
+    public void createTask_dtoFieldsMappedToService() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(42L);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk());
+
+        ArgumentCaptor<CreateTaskDTO> captor = ArgumentCaptor.forClass(CreateTaskDTO.class);
+        verify(taskService).createTask(eq(userId), captor.capture());
+        CreateTaskDTO dto = captor.getValue();
+        assertNotNull(dto);
+        assertEquals("每天读书30分钟", dto.getTitle());
+        assertEquals("培养阅读习惯", dto.getDescription());
+        assertEquals("学习类", dto.getCategory());
+        assertEquals(Long.valueOf(11L), dto.getMemberId());
+        assertEquals(Integer.valueOf(10), dto.getPoints());
+        assertEquals(Integer.valueOf(30), dto.getDuration());
+        assertEquals("recurring", dto.getTaskType());
+        assertEquals("daily", dto.getFrequency());
+        assertEquals(Integer.valueOf(1), dto.getNeedReview());
+        assertEquals("child", dto.getExecutorType());
+        assertEquals("wisdom", dto.getDimensionCode());
+    }
+
+    // ==================== 关键边界:多成员下发 ====================
+
+    @Test
+    public void createTask_multiMember_memberIdsForwarded() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(100L);
+        String body = "{\"title\":\"早晚刷牙\",\"memberIds\":[11,12],\"points\":5,"
+                + "\"frequency\":\"daily\",\"executorType\":\"child\"}";
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").value(100));
+
+        ArgumentCaptor<CreateTaskDTO> captor = ArgumentCaptor.forClass(CreateTaskDTO.class);
+        verify(taskService).createTask(eq(userId), captor.capture());
+        assertEquals(Arrays.asList(11L, 12L), captor.getValue().getMemberIds());
+    }
+
+    // ==================== 传输层契约 ====================
+
+    @Test
+    public void createTask_onlyPostAllowed_getReturns405() throws Exception {
+        mockMvc.perform(get(URL))
+                .andExpect(status().isMethodNotAllowed());
+    }
+
+    @Test
+    public void createTask_missingUserIdAttr_rejected400() throws Exception {
+        // @RequestAttribute("userId") required=true:缺失时 Spring 抛 ServletRequestBindingException,
+        // 由 DefaultHandlerExceptionResolver 映射为 400(JwtInterceptor 负责注入该属性,缺失即未认证)。
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY))
+                .andExpect(status().isBadRequest());
+
+        verify(taskService, never()).createTask(any(), any());
+    }
+
+    // ==================== 关键边界:Service 业务拒绝(异常不吞) ====================
+
+    @Test
+    public void createTask_serviceRejects_illegalArgumentNotSwallowedByHandler() {
+        // 契约:handler 无 try/catch,Service 校验失败(家庭不足2人 / 成员不属于本家庭 / 频率参数非法等)
+        // 直接上抛,生产环境由 @RestControllerAdvice GlobalExceptionHandler 统一转 Result.error(400, message)。
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("任务");
+        dto.setMemberId(11L);
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class)))
+                .thenThrow(new IllegalArgumentException("该任务需分配给其他家庭成员,当前家庭不足2人"));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> controller.createTask(userId, dto));
+        assertEquals("该任务需分配给其他家庭成员,当前家庭不足2人", ex.getMessage());
+
+        verify(taskService).createTask(eq(userId), any(CreateTaskDTO.class));
+    }
+
+    // ==================== 直接调用级:统一 Result<T> ====================
+
+    @Test
+    public void createTask_directCall_uniformResultShape() {
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("每天读书30分钟");
+        dto.setMemberId(11L);
+        dto.setPoints(10);
+        dto.setFrequency("daily");
+        dto.setExecutorType("child");
+        when(taskService.createTask(userId, dto)).thenReturn(77L);
+
+        Result<Long> result = controller.createTask(userId, dto);
+
+        assertNotNull(result);
+        assertEquals(Integer.valueOf(200), result.getCode());
+        assertEquals("success", result.getMessage());
+        assertEquals(Long.valueOf(77L), result.getData());
+        verify(taskService).createTask(userId, dto);
+    }
+}

+ 256 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceWechatLoginContractTest.java

@@ -0,0 +1,256 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.WechatLoginDTO;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+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 java.util.Collections;
+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-02 微信一键登录 — 服务层边界契约单测(能力键 {@code auth.guest.wechatLogin})。
+ *
+ * <p>聚焦 {@code UserService.wechatLogin} 中「建号 / 验重 / 授权失败不建脏号」的真实业务边界,
+ * 补齐 {@code AuthWechatLoginContractTest} 无法触达的关键分支:
+ * <ul>
+ *   <li><b>TC-G-02 正常流</b>:全新 openid → 自动创建 User(role=parent)+ 家庭 + 家庭成员;
+ *       {@code isNewUser=true}、{@code needsRoleSelect=true}、{@code needsFamily=false}。</li>
+ *   <li><b>TC-G-02a 边界(验重)</b>:同一微信二次登录 → 返回已有账号,{@code isNewUser=false},
+ *       不重复建号 / 不重复建家庭 / 不重复建成员。</li>
+ *   <li><b>TC-G-02-E 异常流</b>:code2Session 返回空 openid 或抛异常 → 抛业务异常,
+ *       且 <b>绝不调用 insert(不建脏号)</b>。</li>
+ *   <li><b>角色边界</b>:老用户重登保留原角色(如 teacher 角色不被覆盖),不因重登降级。</li>
+ * </ul>
+ *
+ * <p>纯单元测试:Mockito mock {@code WechatService / UserMapper / FamilyMapper / FamilyMemberMapper /
+ * JwtConfig},不依赖数据库 / 真实微信 / Spring 容器。
+ */
+public class UserServiceWechatLoginContractTest {
+
+    @Mock
+    private WechatService wechatService;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMapper familyMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private OnboardingService onboardingService;
+    @Mock
+    private CouponService couponService;
+
+    @InjectMocks
+    private UserService userService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private Map<String, String> session(String openid) {
+        Map<String, String> m = new HashMap<>();
+        m.put("openid", openid);
+        m.put("unionid", "unionid-" + openid);
+        m.put("session_key", "sk-" + openid);
+        return m;
+    }
+
+    private void stubAutoIds() {
+        doAnswer(inv -> {
+            User u = inv.getArgument(0);
+            u.setId(1L);
+            return 1;
+        }).when(userMapper).insert(any(User.class));
+        doAnswer(inv -> {
+            Family f = inv.getArgument(0);
+            f.setId(100L);
+            return 1;
+        }).when(familyMapper).insert(any(Family.class));
+        when(familyMemberMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    // ====================================================================
+    // 正常流 TC-G-02:全新 openid → 建 User(role=parent) + 家庭 + 成员
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_NewOpenid_CreatesParentUserWithFamily() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+        dto.setNickname("微信用户");
+        dto.setAvatar("avatar-url");
+
+        when(wechatService.code2Session("wx-code")).thenReturn(session("openid-new"));
+        when(userMapper.selectOne(any())).thenReturn(null); // openid 与 phone 都查不到
+        stubAutoIds();
+        when(jwtConfig.generateToken(1L, "parent")).thenReturn("jwt-token-1");
+
+        LoginResultDTO result = userService.wechatLogin(dto);
+
+        // 契约断言以 LoginResultDTO 字段为准(无 code 字段;HTTP 包装见 AuthWechatLoginContractTest)
+        assertEquals("jwt-token-1", result.getToken());
+        assertEquals(1L, result.getUserId());
+        assertEquals("parent", result.getRole(), "新用户角色必须默认 parent");
+        assertEquals(100L, result.getFamilyId(), "应自动创建家庭并绑定");
+        assertTrue(result.getIsNewUser(), "首次登录 isNewUser=true");
+        assertTrue(result.getNeedsRoleSelect(), "新用户应引导选择角色");
+        assertFalse(result.getNeedsFamily(), "已自动建家庭则 needsFamily=false");
+        assertEquals("openid-new", result.getOpenid());
+        assertEquals("微信用户", result.getNickname());
+
+        // 契约:确实创建了 User + Family + FamilyMember
+        verify(userMapper, atLeastOnce()).insert(any(User.class));
+        verify(familyMapper, atLeastOnce()).insert(any(Family.class));
+        verify(familyMemberMapper, atLeastOnce()).insert(any(FamilyMember.class));
+        verify(couponService).grantFamilyRegisterCoupons(eq(100L), anyString());
+        verify(jwtConfig).generateToken(1L, "parent");
+    }
+
+    // ====================================================================
+    // 边界 TC-G-02a:同一微信二次登录 → 已有账号,不重复建号/建家庭/建成员
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_ExistingOpenid_ReturnsExistingNoDuplicateCreation() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        User existing = new User();
+        existing.setId(55L);
+        existing.setOpenid("openid-existing");
+        existing.setRole("parent");
+        existing.setFamilyId(200L);
+        existing.setNickname("老用户");
+        existing.setPhone("13900000002");
+        existing.setSessionKey("old-sk");
+        when(wechatService.code2Session("wx-code")).thenReturn(session("openid-existing"));
+        when(userMapper.selectOne(any())).thenReturn(existing); // openid 命中
+        when(jwtConfig.generateToken(55L, "parent")).thenReturn("jwt-token-2");
+
+        LoginResultDTO result = userService.wechatLogin(dto);
+
+        assertEquals(55L, result.getUserId(), "应返回已有账号 userId");
+        assertFalse(result.getIsNewUser(), "老用户重登 isNewUser 必须为 false");
+        assertFalse(result.getNeedsRoleSelect(), "老用户不再引导选择角色");
+        assertFalse(result.getNeedsFamily());
+        assertEquals("openid-existing", result.getOpenid());
+        assertEquals("老用户", result.getNickname());
+
+        // 契约:不重复建号 / 不重复建家庭 / 不重复建成员;仅刷新 sessionKey(updateById)
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+        verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
+        verify(userMapper, atLeastOnce()).updateById(existing);
+        verify(couponService, never()).grantFamilyRegisterCoupons(anyLong(), anyString());
+    }
+
+    // ====================================================================
+    // 异常流 TC-G-02-E:code2Session 空 openid / 抛异常 → 抛业务异常,不建脏号
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_Code2SessionEmptyOpenid_ThrowsAndNoDirtyAccount() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        Map<String, String> noOpenid = new HashMap<>();
+        noOpenid.put("unionid", "u1");
+        noOpenid.put("session_key", "sk");
+        when(wechatService.code2Session("wx-code")).thenReturn(noOpenid);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> userService.wechatLogin(dto));
+        assertEquals("获取openid失败,请重试", ex.getMessage());
+
+        // 契约:授权失败绝不落库(不建脏号)
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+        verify(userMapper, never()).updateById(any(User.class));
+    }
+
+    @Test
+    public void wechatLogin_Code2SessionThrows_PropagatesAndNoDirtyAccount() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        when(wechatService.code2Session("wx-code"))
+                .thenThrow(new RuntimeException("微信服务不可用"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> userService.wechatLogin(dto));
+        assertEquals("微信服务不可用", ex.getMessage());
+
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+    }
+
+    // ====================================================================
+    // 边界:phoneCode 提供时,新用户绑定真实手机号
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_WithPhoneCode_BindsPhoneToNewUser() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+        dto.setPhoneCode("phone-code-1");
+
+        when(wechatService.code2Session("wx-code")).thenReturn(session("openid-phone"));
+        when(wechatService.getPhoneNumber("phone-code-1")).thenReturn("13800000003");
+        when(userMapper.selectOne(any())).thenReturn(null); // openid 与 phone 都查不到
+        stubAutoIds();
+        when(jwtConfig.generateToken(1L, "parent")).thenReturn("jwt-token-3");
+
+        LoginResultDTO result = userService.wechatLogin(dto);
+
+        assertEquals("13800000003", result.getPhone(), "应绑定微信手机号");
+        assertEquals("openid-phone", result.getOpenid());
+        assertTrue(result.getIsNewUser());
+    }
+
+    // ====================================================================
+    // 角色边界:老用户重登保留原角色(如 teacher 不被覆盖/降级)
+    // ====================================================================
+
+    @Test
+    public void wechatLogin_ExistingTeacherUser_KeepsTeacherRole() {
+        WechatLoginDTO dto = new WechatLoginDTO();
+        dto.setCode("wx-code");
+
+        User teacher = new User();
+        teacher.setId(66L);
+        teacher.setOpenid("openid-teacher");
+        teacher.setRole("teacher");
+        teacher.setRoles(Collections.singletonList("teacher"));
+        teacher.setFamilyId(300L);
+        teacher.setNickname("规划师");
+        when(wechatService.code2Session("wx-code")).thenReturn(session("openid-teacher"));
+        when(userMapper.selectOne(any())).thenReturn(teacher);
+        when(jwtConfig.generateToken(66L, "teacher")).thenReturn("jwt-token-4");
+
+        LoginResultDTO result = userService.wechatLogin(dto);
+
+        assertEquals("teacher", result.getRole(), "重登不得覆盖已有 teacher 角色");
+        assertTrue(result.getHasTeacherRole());
+        assertFalse(result.getIsNewUser());
+        verify(userMapper, never()).insert(any(User.class));
+    }
+}