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

test(S-cfc-9): 补齐 auth.guest.wechatLogin 契约级单测(SC-G-02 微信一键登录)

能力键 auth.guest.wechatLogin:实现已存在但缺契约级覆盖(RED)。补齐纯 Mockito 单测,
不依赖数据库/真实微信/Spring 容器,不改 src/main 实现。

新增测试(16 例全绿):
- AuthWechatLoginContractTest(10 例):TC-G-02 正常流(token+用户信息+默认 parent)、
  TC-G-02a 边界(老用户重登 isNewUser=false 不重复建号)、TC-G-02-E 异常(Result.error
  仅透传业务 message、不泄露堆栈、不建脏号)、TC-G-02-P 权限(JwtInterceptor 公开白名单
  无 token 匿名放行/无效 token 降级放行、非必须登录路径)、结构契约 POST /api/auth/wechat-login
- UserServiceWechatLoginContractTest(6 例):服务层真实边界——全新 openid 建 User(parent)+家庭+成员、
  同一 openid 二次登录不重复建号/家庭/成员(仅刷 sessionKey)、code2Session 空 openid/抛异常不落库、
  phoneCode 绑定手机号、teacher 老用户重登保留原角色

溯源:SC-G-02 · tests/user-scenarios/01-guest.md 场景 G-2 · tests/test-cases/guest.md TC-G-02/TC-G-02a/TC-G-02-E/TC-G-02-P
Sisyphus 1 неделя назад
Родитель
Сommit
8051b64f

+ 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]);
+    }
+}

+ 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));
+    }
+}