Explorar o código

merge(tdd): 合入 SC-G-02 微信授权登录场景证据(契约测试+权限流测试+e2e用例+执行报告,S-cfc-9)

Agnes Agent hai 1 semana
pai
achega
3aebf9b168

+ 130 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorWechatLoginTest.java

@@ -0,0 +1,130 @@
+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.context.SecurityContextHolder;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+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;
+
+/**
+ * SC-G-02 微信一键登录 权限流(TC-G-02-P / TC-G-06)JwtInterceptor 鉴权行为契约测试。
+ *
+ * <p>口径(tests/user-scenarios/01-guest.md 场景 G-5/G-6、API_REFERENCE 白名单):
+ * <ol>
+ *   <li>{@code POST /api/auth/wechat-login} 是公开路径:游客无 token 可匿名触达(正常流前置)。</li>
+ *   <li>受保护接口({@code /api/family/member/list} 属 LOGIN_REQUIRED_PATHS)无 token → 统一 401
+ *       「请先登录」,不泄露内部错误堆栈。</li>
+ *   <li>携带有效 token → 放行并注入 userId/role/familyId/currentMemberId(供下游家庭数据访问)。</li>
+ *   <li>携带无效/过期 token → 统一 401。</li>
+ * </ol>
+ */
+public class JwtInterceptorWechatLoginTest {
+
+    @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();
+    }
+
+    private MockHttpServletRequest post(String uri) {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI(uri);
+        return request;
+    }
+
+    /** 权限流-1:微信登录是公开路径,游客无 token 匿名放行(不 401) */
+    @Test
+    public void wechatLogin公开路径_无token匿名放行() throws Exception {
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(post("/api/auth/wechat-login"), response, null);
+        assertTrue(result);
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+        assertNull(response.getHeader("X-User-Id"));
+    }
+
+    /** 权限流-2:受保护接口(家庭成员列表)无 token → 统一 401「请先登录」 */
+    @Test
+    public void 受保护接口无token_统一401() throws Exception {
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(post("/api/family/member/list"), response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+
+    /** 权限流-3:携带有效 token 访问受保护接口 → 放行并注入 userId/role/familyId/currentMemberId */
+    @Test
+    public void 携带有效token访问受保护接口_放行并注入用户上下文() throws Exception {
+        MockHttpServletRequest request = post("/api/family/member/list");
+        request.addHeader("Authorization", "Bearer valid-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn("5001");
+        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(5001L);
+        user.setRole("parent");
+        user.setFamilyId(20001L);
+        when(userMapper.selectById(5001L)).thenReturn(user);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(8888L);
+        member.setFamilyId(20001L);
+        member.setUserId(5001L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertTrue(result);
+        assertEquals(5001L, request.getAttribute("userId"));
+        assertEquals("parent", request.getAttribute("role"));
+        assertEquals(20001L, request.getAttribute("familyId"));
+        assertEquals(8888L, request.getAttribute("currentMemberId"));
+    }
+
+    /** 权限流-4:携带无效/过期 token → 统一 401,不降级匿名放行 */
+    @Test
+    public void 携带无效token访问受保护接口_401() throws Exception {
+        MockHttpServletRequest request = post("/api/family/member/list");
+        request.addHeader("Authorization", "Bearer garbage-token");
+        when(jwtConfig.parseToken("garbage-token")).thenThrow(new RuntimeException("JWT 过期"));
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+    }
+}

+ 240 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceWechatLoginTest.java

@@ -0,0 +1,240 @@
+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.ArgumentCaptor;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-G-02 微信一键登录(auth.guest.wechatLogin / S-cfc-9)服务层契约测试。
+ *
+ * <p>口径(docs/flows/user-auth-flow.md §阶段一、tests/user-scenarios/01-guest.md 场景 G-2):
+ * <ol>
+ *   <li>正常流:微信 code → code2Session → openid;无账号则自动创建 User(默认角色 parent)+
+ *       自动建家庭 + family_members 成员记录;签发 JWT。</li>
+ *   <li>异常流:code2Session 取不到 openid / 微信接口异常 → 抛出业务异常,<b>不产出脏号</b>
+ *       (不 insert user/family/family_member)。</li>
+ *   <li>幂等:同一 openid 二次登录复用已有账号(isNewUser=false),不重复建号/建家庭;
+ *       手机号已存在但 openid 不同 → 绑定 openid,不新建。</li>
+ * </ol>
+ *
+ * <p>采用 Mockito 纯单元测试(不启动 Spring、不连数据库),以 MyBatis-Plus 自增主键回填模拟
+ * ({@code insert} 后回写实体 id),断言建档与令牌签发结果。</p>
+ */
+class UserServiceWechatLoginTest {
+
+    @Mock
+    private WechatService wechatService;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMapper familyMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private JwtConfig jwtConfig;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private OnboardingService onboardingService;
+
+    @InjectMocks
+    private UserService userService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private WechatLoginDTO dto(String code) {
+        WechatLoginDTO d = new WechatLoginDTO();
+        d.setCode(code);
+        return d;
+    }
+
+    private Map<String, String> session(String openid, String unionid) {
+        Map<String, String> m = new HashMap<>();
+        m.put("openid", openid);
+        m.put("unionid", unionid);
+        m.put("session_key", "sk-" + openid);
+        return m;
+    }
+
+    /** 模拟 MyBatis-Plus 自增主键回填:insert 后回写实体 id */
+    private void autoKey() {
+        doAnswer(inv -> {
+            User u = inv.getArgument(0);
+            u.setId(9001L);
+            return 1;
+        }).when(userMapper).insert(any(User.class));
+        doAnswer(inv -> {
+            Family f = inv.getArgument(0);
+            f.setId(90001L);
+            return 1;
+        }).when(familyMapper).insert(any(Family.class));
+    }
+
+    // ==================== 正常流 ====================
+
+    /**
+     * 正常流(TC-G-02):微信首次登录 → 自动创建 User(role=parent)+ 家庭 + family_members 成员,
+     * 返回 Result 含 token + 用户信息,isNewUser=true。
+     */
+    @Test
+    void 首次微信登录_自动建档默认家长并创建家庭与成员() {
+        autoKey();
+        when(wechatService.code2Session("wx-code-1")).thenReturn(session("openid_first", "union_1"));
+        when(userMapper.selectOne(any())).thenReturn(null);
+        when(familyMemberMapper.selectCount(any())).thenReturn(0L);
+        when(jwtConfig.generateToken(9001L, "parent")).thenReturn("jwt-token-first");
+
+        LoginResultDTO result = userService.wechatLogin(dto("wx-code-1"));
+
+        assertNotNull(result.getToken());
+        assertEquals("jwt-token-first", result.getToken());
+        assertEquals("parent", result.getRole());
+        assertTrue(result.getIsNewUser());
+        assertEquals(9001L, result.getUserId());
+        assertEquals(90001L, result.getFamilyId());
+        assertEquals("openid_first", result.getOpenid());
+        assertFalse(result.getNeedsFamily());      // 自动建档已建家庭
+        assertTrue(result.getNeedsRoleSelect());   // 新用户需要选择角色
+        assertFalse(result.getHasTeacherRole());
+
+        // users 表 insert(默认 parent、家庭管理员)
+        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).insert(userCaptor.capture());
+        User created = userCaptor.getValue();
+        assertEquals("openid_first", created.getOpenid());
+        assertEquals("parent", created.getRole());
+        assertTrue(created.getIsFamilyAdmin());
+
+        // families 表 insert
+        verify(familyMapper).insert(any(Family.class));
+
+        // family_members 表 insert:创建人自己是成员(能登录成员,VO 层派生 memberType=login)
+        ArgumentCaptor<FamilyMember> memberCaptor = ArgumentCaptor.forClass(FamilyMember.class);
+        verify(familyMemberMapper).insert(memberCaptor.capture());
+        assertEquals(9001L, memberCaptor.getValue().getUserId());
+        assertEquals(90001L, memberCaptor.getValue().getFamilyId());
+    }
+
+    /**
+     * 正常流-幂等(TC-G-02a):同一 openid 二次登录 → 复用已有账号(isNewUser=false),
+     * 不重复建号、不重复建家庭、不重复建成员;仅刷新 sessionKey。
+     */
+    @Test
+    void 同一openid二次登录_复用账号不重复建号不重复建家庭() {
+        User existing = new User();
+        existing.setId(5001L);
+        existing.setOpenid("openid_first");
+        existing.setRole("parent");
+        existing.setFamilyId(20001L);
+        existing.setNickname("老用户");
+        when(wechatService.code2Session("wx-code-1")).thenReturn(session("openid_first", "union_1"));
+        when(userMapper.selectOne(any())).thenReturn(existing);
+        when(jwtConfig.generateToken(5001L, "parent")).thenReturn("jwt-token-again");
+
+        LoginResultDTO result = userService.wechatLogin(dto("wx-code-1"));
+
+        assertFalse(result.getIsNewUser());
+        assertEquals(5001L, result.getUserId());
+        assertEquals(20001L, result.getFamilyId());
+        assertFalse(result.getNeedsRoleSelect());
+
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+        verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
+        // 老用户:刷新 sessionKey
+        verify(userMapper).updateById(existing);
+        assertEquals("sk-openid_first", existing.getSessionKey());
+    }
+
+    /**
+     * 正常流-变体:手机号已存在但 openid 不同 → 绑定 openid(不新建账号、isNewUser=false)。
+     */
+    @Test
+    void 手机号已存在但openid不同_绑定openid不新建() {
+        User phoneUser = new User();
+        phoneUser.setId(6002L);
+        phoneUser.setPhone("13800000001");
+        phoneUser.setOpenid(null);
+        phoneUser.setRole("parent");
+        phoneUser.setFamilyId(30001L);
+        when(wechatService.code2Session("wx-phone")).thenReturn(session("openid_bound", "union_2"));
+        when(wechatService.getPhoneNumber("pc-1")).thenReturn("13800000001");
+        when(userMapper.selectOne(any())).thenReturn(null, phoneUser); // 先按 openid 查不到,再按手机号查到
+        when(jwtConfig.generateToken(6002L, "parent")).thenReturn("jwt-token-bound");
+
+        WechatLoginDTO d = dto("wx-phone");
+        d.setPhoneCode("pc-1");
+        LoginResultDTO result = userService.wechatLogin(d);
+
+        assertFalse(result.getIsNewUser());
+        assertEquals(6002L, result.getUserId());
+        assertEquals("openid_bound", phoneUser.getOpenid());
+        verify(userMapper, never()).insert(any(User.class));
+        verify(userMapper).updateById(phoneUser);
+    }
+
+    // ==================== 异常流 ====================
+
+    /**
+     * 异常流(TC-G-02-E #1):code2Session 取不到 openid(授权失败/回调超时)→ 业务异常,
+     * 不产出脏号(不 insert user/family/family_member)。
+     */
+    @Test
+    void code2Session返回空openid_抛出业务异常且不产出脏号() {
+        when(wechatService.code2Session("bad-code")).thenReturn(Collections.singletonMap("session_key", "sk"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> userService.wechatLogin(dto("bad-code")));
+        assertEquals("获取openid失败,请重试", ex.getMessage());
+
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+        verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
+        verify(userMapper, never()).updateById(any(User.class));
+    }
+
+    /**
+     * 异常流(TC-G-02-E #3):微信接口异常(超时/网络)→ 抛出异常,不建脏号。
+     */
+    @Test
+    void 微信接口异常_不产出脏号() {
+        when(wechatService.code2Session(anyString())).thenThrow(new RuntimeException("微信接口超时"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> userService.wechatLogin(dto("timeout-code")));
+        assertNotNull(ex.getMessage());
+
+        verify(userMapper, never()).insert(any(User.class));
+        verify(familyMapper, never()).insert(any(Family.class));
+        verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
+    }
+}

+ 115 - 0
docs/tdd/test/automation-run-2026-11-05-0555.md

@@ -0,0 +1,115 @@
+# CFC 自动化测试执行台账 · 2026-11-05 05:55 — SC-G-02 微信授权登录 场景打通(研发负责人闭环轮)
+
+> 场景:`SC-G-02 微信一键登录(默认成为家长)`(S-cfc-9,auth.guest.wechatLogin)
+> 需求锚点:`tests/user-scenarios/应用场景总览.md` 二、游客 · `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
+> 设计锚点:`docs/flows/user-auth-flow.md`(`POST /api/auth/wechat-login` → `WechatService.code2Session` → 建档 + JWT)+ `docs/superpowers/api/API_REFERENCE.md` 4.1(AuthController `/api/auth`)
+> 分支:`feat/sc-g-02-wechat-login`(基于 `origin/cfclub` @ `345cf745e` 新建独立 worktree)
+
+---
+
+## 1. 本轮目标与判据
+
+按 `docs/tdd/orchestration/DEV_GOAL_user_scenarios.md`「跑通」三判据,为 SC-G-02 补齐并**实际执行**派生用例:
+
+| 流 | 派生用例 | 执行证据 | 状态 |
+|----|---------|---------|------|
+| 正常流 | TC-G-02(首次微信登录自动建 parent 账号+家庭+family_members) | `UserServiceWechatLoginTest`(服务层契约,5/5 绿)+ HTTP 验收 | ✅ 通过 |
+| 异常流 | TC-G-02-E(openid 取不到/微信接口异常 → 干净报错、不建脏号;同 openid 二次登录验重不重复建档) | `UserServiceWechatLoginTest`(异常分支 + 幂等分支)+ HTTP 验收 | ✅ 通过 |
+| 权限流 | TC-G-02-P / TC-G-06(wechat-login 为公开路径可匿名触达;受保护接口无 token→401;有效 token 放行注入上下文;跨家庭→403) | `JwtInterceptorWechatLoginTest`(4/4 绿)+ `tests/e2e/api/wechat-login.spec.js` + HTTP 验收 | ✅ 通过 |
+
+> 原始场景/用例文件(`tests/user-scenarios/*`、`tests/test-cases/*`)**只读**,未做任何创建/修改/发明。
+
+---
+
+## 2. 真实动作清单
+
+### 2.1 分支与 worktree(收口纪律)
+- `git fetch origin cfclub` → 基于 `origin/cfclub`(`345cf745e`)`git worktree add` 建独立目录 + 分支 `feat/sc-g-02-wechat-login`。
+- 全程仅在独立 worktree 内改动;未 checkout/stash/force-push 他人改动,未触碰 E:/cfc 主工作树。
+
+### 2.2 新增/改动文件(精确 `git add --`,无 `git add -A`)
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceWechatLoginTest.java` | 新增 | 服务层契约测试(正常/异常/幂等) |
+| `cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorWechatLoginTest.java` | 新增 | 权限流鉴权契约测试 |
+| `tests/e2e/api/wechat-login.spec.js` | 新增 | API 级 e2e 用例(正常/权限流,PW_BASE_URL 可切换目标端) |
+| `docs/tdd/test/automation-run-2026-11-05-0555.md` | 新增 | 本执行证据报告 |
+
+> 实现代码(`AuthController.wechatLogin` / `UserService.wechatLogin` / `JwtInterceptor` 白名单)在 `origin/cfclub` **已存在**,本场景无需新增业务代码;本轮为补用例 + 实跑留证。
+
+### 2.3 编译/构建闸门
+- `mvn -q -DskipTests compile`(cfc-backend):**通过**(exit 0)。
+- `mvn test -Dtest=UserServiceWechatLoginTest,JwtInterceptorWechatLoginTest -DskipTests=false`:**testCompile 207 源 0 错**、9 用例(详见 §3)。
+- `cfc-web`:本轮 diff **不含任何 cfc-web 文件**(`git diff --name-only` 佐证),构建闸门对该 diff 为 N/A;worktree 内无 cfc-web node_modules,未做无谓的全量 npm install。
+
+---
+
+## 3. 契约测试执行结果(surefire,`-DskipTests=false`)
+
+```
+mvn test -Dtest=UserServiceWechatLoginTest,JwtInterceptorWechatLoginTest -DskipTests=false
+```
+
+| 测试类 | 覆盖流 | 用例 | 结果 |
+|--------|-------|------|------|
+| `com.etotem.cfc.service.UserServiceWechatLoginTest` | 正常流+异常流 | 5 | **5/5 绿** |
+| `com.etotem.cfc.config.JwtInterceptorWechatLoginTest` | 权限流 | 4 | **4/4 绿** |
+| **合计** | 三类 | 9 | **9/9 绿,0 失败 0 错误** |
+
+用例明细:
+
+| 用例 | 流 | 断言要点 |
+|------|----|---------|
+| 首次微信登录_自动建档默认家长并创建家庭与成员 | 正常 | isNewUser=true、role=parent、jwt token 签发;users insert(parent+isFamilyAdmin)、families insert、family_members insert(自建成员,userId 回填,VO 派生 memberType=login) |
+| 同一openid二次登录_复用账号不重复建号不重复建家庭 | 正常/幂等 | isNewUser=false、同 userId/familyId;**不** insert user/family/family_member;仅 refresh sessionKey |
+| 手机号已存在但openid不同_绑定openid不新建 | 正常/变体 | 按手机号命中 → 绑定新 openid,不新建账号 |
+| code2Session返回空openid_抛出业务异常且不产出脏号 | 异常 | 抛「获取openid失败,请重试」;user/family/family_member **零 insert** |
+| 微信接口异常_不产出脏号 | 异常 | 微信超时异常冒泡;**零 insert**(不建脏号) |
+| wechatLogin公开路径_无token匿名放行 | 权限 | `/api/auth/wechat-login` 无 token → 放行(游客可触达,不 401) |
+| 受保护接口无token_统一401 | 权限 | `/api/family/member/list` 无 token → 401「请先登录」,无内部堆栈 |
+| 携带有效token访问受保护接口_放行并注入用户上下文 | 权限 | 放行;注入 userId/role/familyId/currentMemberId |
+| 携带无效token访问受保护接口_401 | 权限 | 过期/伪造 token → 统一 401,不降级匿名 |
+
+> 证据文件:`cfc-backend/target/surefire-reports/TEST-com.etotem.cfc.service.UserServiceWechatLoginTest.xml`、`TEST-com.etotem.cfc.config.JwtInterceptorWechatLoginTest.xml`(本机执行产物)。
+
+---
+
+## 4. HTTP 层验收记录(本地后端 + 测试库 192.168.16.251,wechat.test-mode=true)
+
+> 后端 `mvn spring-boot:run` 本机起服(9082,`jdbc:mysql://192.168.16.251:3306/zxyj`),`DatabaseInitializer` 完成迁移后以真实 HTTP 请求逐步骤留证(验收记录口径,不依赖 Playwright 运行时)。
+
+| 步骤 | 请求 | 实际响应(关键字段) | 结果 |
+|------|------|---------------------|------|
+| 1 正常流 | `POST /api/auth/wechat-login {code,nickname}`(游客无 token) | http=200,code=200,role=**parent**,isNewUser=**true**,userId=84287,familyId=5425,openid(20) 与 token(197) 非空,needsFamily=false | ✅ |
+| 2 正常流-建档 | `POST /api/family/member/list`(携步骤1 token) | http=200,code=200,data[0] = `{memberType:"login", userId:84287, source:"family_member", effectiveRole:"parent", generation:0}` —— 首次登录自动建档并落库 family_members | ✅ |
+| 3 权限流-未登录 | `POST /api/family/member/list`(无 token) | http=**401**(未授权),响应 `{"code":401,"message":"请先登录"}`,无内部堆栈 | ✅ |
+| 4 权限流-越权 | `POST /api/family/member/list {familyId:999999999}`(携 token) | http=**403**,code=403(无权访问),横向越权被拒 | ✅ |
+| 5 权限流-公开路径 | `POST /api/auth/wechat-login`(无 token) | http=200 —— 游客可匿名触达登录端点 | ✅ |
+
+> 说明:测试模式下 `code2Session` 每次生成新的模拟 openid(`test_openid_<rand>`),故同一 code 无法在 HTTP 层复现「同 openid 验重」;该幂等/验重逻辑已由 `UserServiceWechatLoginTest` 契约测试覆盖(§3),HTTP 层已验证「新用户首次建档 + token 签发 + 家庭成员 memberType=login」。
+
+---
+
+## 5. 结论(三判据对照)
+
+1. ✅ **三类用例均有实际执行证据**:JUnit 契约测试 **9/9 绿**(surefire 报告,§3)+ HTTP 层验收记录 5 步骤全过(§4)+ API 级 e2e 用例资产 `tests/e2e/api/wechat-login.spec.js`(正常/权限流,PW_BASE_URL 可切换目标端;本机未装 `@playwright/test` 运行时,该用例以 §4 真实 HTTP 验收等价覆盖)。
+2. ✅ **实现代码 + 用例/报告已 push 到 origin feature 分支**:commit sha 见 §6,`git merge-base --is-ancestor <sha> origin/feat/sc-g-02-wechat-login` 可核验。
+3. ✅ **本地工作目录干净**:`git status --porcelain` 为空(§7)。
+
+## 6. 提交信息
+
+- 分支:`feat/sc-g-02-wechat-login`
+- commit sha:(见 §8 回填)
+- 提交内容:`SC-G-02 微信授权登录场景打通:新增服务层契约测试(正常/异常/幂等)+ JwtInterceptor 权限流契约测试 + API 级 e2e 用例 + 执行证据报告`
+
+## 7. clean-check
+
+- `git status --porcelain` → 空(提交后回填确认)
+- `.tdd/` 未改动、未推送;未使用 `git add -A`/`git add .`;提交前 `git diff --cached --name-only` 复核文件清单
+
+## 8. 提交核验(回填)
+
+- commit sha:`8db39fec980cc028c77fe518980c53d0240dd192`(本报告与全部用例随该提交推送,见下)+ 本核验回填追加提交
+- `git merge-base --is-ancestor 8db39fec9 origin/feat/sc-g-02-wechat-login`:**exit 0 = 通过**(local sha == remote sha)
+- `git diff --cached --name-only` 文件数:4(两个 Java 契约测试 + API e2e 用例 + 本报告)
+- `git status --porcelain`:**空**(clean-check 通过)

+ 100 - 0
tests/e2e/api/wechat-login.spec.js

@@ -0,0 +1,100 @@
+/**
+ * SC-G-02 微信授权登录 · API 级 e2e(auth.guest.wechatLogin / S-cfc-9)
+ * ================================================================
+ * 覆盖:
+ *   - 正常流(TC-G-02):POST /api/auth/wechat-login → Result 含 token + 用户信息,
+ *     角色默认 parent;新微信自动建 User + 家庭 + family_members 成员(自己能查到)。
+ *   - 权限流(TC-G-02-P):微信登录是公开路径(游客无 token 可触达);受保护接口
+ *     /api/family/member/list 无 token → 401;跨家庭 familyId → 403(越权被拒)。
+ *   - 边界(TC-G-02-E):登录响应不泄露内部错误堆栈(Result 统一 code/message/data 信封)。
+ *
+ * 目标端由 PW_BASE_URL 指定(默认 http://cfc.iwintrue.com,本地验证用
+ * http://127.0.0.1:9082,后端开 wechat.test-mode=true)。
+ *
+ * 运行:npx playwright test wechat-login -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+
+const BASE = process.env.PW_BASE_URL || 'http://cfc.iwintrue.com';
+const API_TIMEOUT = 60000;
+
+/**
+ * 统一 POST(全项目接口一律 POST),返回 { http, code, message, data }
+ */
+async function api(request, path, body, token) {
+  const headers = { 'Content-Type': 'application/json' };
+  if (token) headers.Authorization = 'Bearer ' + token;
+  const res = await request.post(BASE + path, { data: body || {}, headers, timeout: API_TIMEOUT });
+  const raw = await res.text();
+  let json = null;
+  try {
+    json = raw ? JSON.parse(raw) : null;
+  } catch (e) {
+    json = null;
+  }
+  return {
+    http: res.status(),
+    code: json ? json.code : null,
+    message: json ? json.message : null,
+    data: json ? json.data : null,
+  };
+}
+
+test.describe('SC-G-02 微信授权登录(API 级)', () => {
+  let ctx = {};
+
+  test.beforeAll(async ({ request }) => {
+    const login = await api(request, '/api/auth/wechat-login', {
+      code: 'scg02-' + Date.now() + '-' + String(Math.random()).slice(2, 8),
+      nickname: 'SC-G-02游客',
+    });
+    expect(login.http).toBe(200);
+    expect(login.code).toBe(200);
+    expect(login.data && login.data.token).toBeTruthy();
+    ctx = login.data;
+  });
+
+  test('正常流:微信一键登录返回 token+用户信息,角色默认 parent', async () => {
+    expect(ctx.role).toBe('parent');
+    expect(ctx.userId).toBeTruthy();
+    expect(ctx.openid).toBeTruthy();
+    expect(typeof ctx.token).toBe('string');
+    expect(ctx.token.length).toBeGreaterThan(20); // JWT 三段式
+  });
+
+  test('正常流:首次登录自动建档,家庭列表能看到自己(family_members 成员)', async ({ request }) => {
+    const members = await api(request, '/api/family/member/list', { includeFamily: true }, ctx.token);
+    expect(members.http).toBe(200);
+    expect(members.code).toBe(200);
+    expect(Array.isArray(members.data)).toBeTruthy();
+    const self = (members.data || []).find((m) => String(m.userId) === String(ctx.userId));
+    expect(self).toBeTruthy();
+  });
+
+  test('权限流:微信登录是公开路径,无 token 也可调用(游客可触达)', async ({ request }) => {
+    const login = await api(request, '/api/auth/wechat-login', {
+      code: 'scg02-anon-' + Date.now(),
+    });
+    expect(login.http).toBe(200);
+    expect(login.code).toBe(200);
+    expect(login.data && login.data.token).toBeTruthy();
+  });
+
+  test('权限流:受保护接口无 token → 统一 401,不泄露内部堆栈', async ({ request }) => {
+    const r = await api(request, '/api/family/member/list', {});
+    expect(r.http).toBe(401);
+    expect(r.code).toBe(401);
+    expect(String(r.message)).toContain('请先登录');
+    expect(JSON.stringify(r)).not.toContain('Exception');
+    expect(JSON.stringify(r)).not.toContain('at com.etotem');
+  });
+
+  test('权限流:登录后携带 token 可访问;跨家庭 familyId → 403 越权被拒', async ({ request }) => {
+    const own = await api(request, '/api/family/member/list', { familyId: ctx.familyId }, ctx.token);
+    expect(own.code).toBe(200);
+    const cross = await api(request, '/api/family/member/list', { familyId: 999999999 }, ctx.token);
+    expect(cross.http).toBe(403);
+    expect(cross.code).toBe(403);
+    expect(String(cross.message)).toContain('无权访问');
+  });
+});