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

Merge origin/cfclub: sync S-cfc-1 family controller test + frontend updates

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

+ 223 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerAddMemberTest.java

@@ -0,0 +1,223 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.dto.AddFamilyMemberDTO;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyMemberService;
+import com.etotem.cfc.service.UnlockGateService;
+import com.etotem.cfc.service.UserService;
+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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+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-1 / 能力键 family.familyMembers.addMember):
+ * POST /api/family/member/add 添加家庭成员 的契约级单测。
+ *
+ * 契约(对应 API_REFERENCE.md §4.2 与 FamilyMembersController.addMember):
+ * - userId == null → Result code=500 message=「请先登录」,不触达 Service
+ * - 正常路径:Service 返回 VO → Result code=200 data=VO;若当前用户有家庭(userMapper.selectById 返回非空且 familyId 非空)
+ *   则额外触发 unlockGateService.refreshFamilyGates(familyId, userId)(创建家人成功后的通关解锁刷新)
+ * - 正常路径但用户查询为空 / 用户无家庭:仍 code=200,但不触发 refreshFamilyGates
+ * - Service 抛 IllegalArgumentException → code=500 message=异常信息
+ * - Service 抛 RuntimeException → code=500 message=异常信息
+ *
+ * 角色/权限口径:addMember 处理器无 @RequestAttribute("role"/"roles"),授权交由 Service 层
+ * (familyId=登录用户所属家庭)与 JwtInterceptor(匿名注入 userId=-1 被 401 拦截)承担;
+ * 本控制器唯一鉴权边界是 userId 空守卫,故本测试覆盖该守卫而非角色分支。
+ * 测试不依赖真实微信/外部服务:FamilyMemberService/UserMapper/UnlockGateService 全 mock。
+ */
+public class FamilyMembersControllerAddMemberTest {
+
+    @Mock private FamilyMemberService familyMemberService;
+    @Mock private UserService userService;
+    @Mock private UnlockGateService unlockGateService;
+    @Mock private UserMapper userMapper;
+
+    private MockMvc mockMvc;
+    private FamilyMembersController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new FamilyMembersController();
+        ReflectionTestUtils.setField(controller, "familyMemberService", familyMemberService);
+        ReflectionTestUtils.setField(controller, "unlockGateService", unlockGateService);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private static final String VALID_BODY = "{"
+            + "\"nickname\":\"小明\","
+            + "\"generationLevel\":\"child\","
+            + "\"relativeMemberId\":5,"
+            + "\"peerType\":null,"
+            + "\"phone\":\"13800001111\","
+            + "\"gender\":\"male\","
+            + "\"birthday\":\"2018-01-01\","
+            + "\"birthWeight\":3200,"
+            + "\"isAdmin\":0"
+            + "}";
+
+    @Test
+    public void addMember_success_userHasFamily_triggersGateRefresh() throws Exception {
+        Long userId = 1L;
+        Long familyId = 7L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(100L);
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class))).thenReturn(vo);
+        when(userMapper.selectById(userId)).thenReturn(user(userId, familyId));
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value(100));
+
+        verify(unlockGateService).refreshFamilyGates(familyId, userId);
+    }
+
+    @Test
+    public void addMember_success_userNotFound_doesNotRefresh() throws Exception {
+        Long userId = 1L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(101L);
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class))).thenReturn(vo);
+        when(userMapper.selectById(userId)).thenReturn(null);
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value(101));
+
+        verify(unlockGateService, never()).refreshFamilyGates(anyLong(), anyLong());
+    }
+
+    @Test
+    public void addMember_success_userWithoutFamily_doesNotRefresh() throws Exception {
+        Long userId = 1L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(102L);
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class))).thenReturn(vo);
+        when(userMapper.selectById(userId)).thenReturn(user(userId, null));
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.id").value(102));
+
+        verify(unlockGateService, never()).refreshFamilyGates(anyLong(), anyLong());
+    }
+
+    @Test
+    public void addMember_illegalArgument_returnsErrorMessage() throws Exception {
+        Long userId = 1L;
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class)))
+                .thenThrow(new IllegalArgumentException("成员昵称不能为空"));
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("成员昵称不能为空"));
+
+        verify(unlockGateService, never()).refreshFamilyGates(anyLong(), anyLong());
+    }
+
+    @Test
+    public void addMember_runtimeException_returnsErrorMessage() throws Exception {
+        Long userId = 1L;
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class)))
+                .thenThrow(new RuntimeException("用户未加入家庭,请先创建或加入家庭"));
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("用户未加入家庭,请先创建或加入家庭"));
+
+        verify(unlockGateService, never()).refreshFamilyGates(anyLong(), anyLong());
+    }
+
+    @Test
+    public void addMember_dtoDeserializedAndPassedToService() throws Exception {
+        Long userId = 1L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(103L);
+        when(familyMemberService.addMember(eq(userId), any(AddFamilyMemberDTO.class))).thenReturn(vo);
+
+        mockMvc.perform(post("/api/family/member/add")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(VALID_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        ArgumentCaptor<AddFamilyMemberDTO> captor = ArgumentCaptor.forClass(AddFamilyMemberDTO.class);
+        verify(familyMemberService).addMember(eq(userId), captor.capture());
+        AddFamilyMemberDTO dto = captor.getValue();
+        assertNotNull(dto);
+        assertEquals("小明", dto.getNickname());
+        assertEquals("child", dto.getGenerationLevel());
+        assertEquals(Long.valueOf(5L), dto.getRelativeMemberId());
+        assertEquals("13800001111", dto.getPhone());
+        assertEquals("male", dto.getGender());
+        assertEquals("2018-01-01", dto.getBirthday());
+        assertEquals(Integer.valueOf(3200), dto.getBirthWeight());
+        assertEquals(Integer.valueOf(0), dto.getIsAdmin());
+    }
+
+    /**
+     * userId 空守卫:MockMvc 的 @RequestAttribute 缺省 required=true 无法注入 null,
+     * 故按 FamilyControllerTest 先例直接调用控制器方法验证该守卫分支。
+     */
+    @Test
+    public void addMember_nullUserId_returnsLoginRequired() {
+        AddFamilyMemberDTO dto = new AddFamilyMemberDTO();
+        dto.setNickname("小明");
+        dto.setGenerationLevel("child");
+
+        com.etotem.cfc.common.Result<FamilyMemberVO> result = controller.addMember(dto, null);
+        assertEquals(Integer.valueOf(500), result.getCode());
+        assertEquals("请先登录", result.getMessage());
+
+        verify(familyMemberService, never()).addMember(anyLong(), any(AddFamilyMemberDTO.class));
+    }
+}

+ 14 - 2
cfc-frontend/pages/index-home/index.vue

@@ -347,8 +347,17 @@
       <text class="how-it-works-hint">登录 / 注册后,按引导依次完成,全家健康即刻开始 ›</text>
       <text class="how-it-works-hint">登录 / 注册后,按引导依次完成,全家健康即刻开始 ›</text>
     </view>
     </view>
 
 
-    <!-- 五行沙盘预览 -->
-    <wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />
+    <!-- 五行沙盘:登录前以 mock 数据展示完整星图(登录后由真实 /api/energy/sandbox 数据替换) -->
+    <wuxing-sandbox
+      mode="live"
+      :scores="wuxingSandboxMock.scores"
+      :overallScore="wuxingSandboxMock.overallScore"
+      :members="wuxingSandboxMock.members"
+      :personalData="wuxingSandboxMock.personalData"
+      :dimensions="wuxingSandboxMock.dimensions"
+      showDimensionBar
+      starHeight="480rpx"
+      @point-click="handleLogin" />
 
 
     <!-- 推荐阅读(无条件展示,置顶) -->
     <!-- 推荐阅读(无条件展示,置顶) -->
     <view class="section">
     <view class="section">
@@ -460,6 +469,7 @@
 <script>
 <script>
 import store from '../../store/index.js'
 import store from '../../store/index.js'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
+import wuxingSandboxMock from '../../utils/wuxing-sandbox-mock.js'
 import GateLock from '../../components/gate-lock.vue'
 import GateLock from '../../components/gate-lock.vue'
 import FamilyFeed from '../../components/FamilyFeed.vue'
 import FamilyFeed from '../../components/FamilyFeed.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -562,6 +572,8 @@ components: {
       ],
       ],
       showProducts: true,
       showProducts: true,
       showArticles: true,
       showArticles: true,
+      // 登录前能量沙盘演示数据(mock,登录后由真实接口数据替换)
+      wuxingSandboxMock: wuxingSandboxMock,
       gradientColors: [
       gradientColors: [
         `linear-gradient(135deg, ${BrandColors.action}, ${BrandColors.successLight3})`,
         `linear-gradient(135deg, ${BrandColors.action}, ${BrandColors.successLight3})`,
         `linear-gradient(135deg, ${BrandColors.info}, ${BrandColors.infoLight})`,
         `linear-gradient(135deg, ${BrandColors.info}, ${BrandColors.infoLight})`,

+ 57 - 0
cfc-frontend/utils/wuxing-sandbox-mock.js

@@ -0,0 +1,57 @@
+/**
+ * 五行能量沙盘 —— 登录前演示数据(mock)。
+ *
+ * 用途:未登录态首页/发现页在用户登录前展示完整的五维能量沙盘(星图 + 维度条 + 成员切换),
+ * 由于此时没有真实的后端数据,使用一组固定演示值。登录后由 /api/energy/sandbox 真实数据替换。
+ *
+ * 数值来源:设计稿 docs/redesign/cfc-home-redesign.html §星图样例
+ * (全家五维能量星图:心 92,身 78,智 74,富 80,行 76,健康指数 82),
+ * 与 wuxing-sandbox.vue 的 prop 结构(scores / overallScore / members / personalData / dimensions)一一对应。
+ *
+ * 约定:scores 为「全家」视角聚合分;personalData 为「本人」视角数据(含 members 数组以定位 self);
+ *       members 为可切换的成员列表(memberId 与 personalData.members[0] 一致者视为本人,切换面板中省略)。
+ */
+
+var WUXING_SANDBOX_MOCK = {
+  // 全家视角五维分(viewMode === 'family' 时读取;overallScore 供 resolveSandboxOverall 读取)
+  scores: {
+    body: 78,
+    mind: 92,
+    wisdom: 74,
+    action: 76,
+    wealth: 80,
+    overallScore: 82
+  },
+  // 太极阳面 / 星图读屏综合分(familyEnergy 与 starAriaLabel 读取)
+  overallScore: 82,
+  // 「本人」视角沙盘数据(viewMode === 'personal' 时读取;members[0] 为 self)
+  personalData: {
+    memberId: 1,
+    name: '小李',
+    bodyScore: 78,
+    mindScore: 92,
+    wisdomScore: 74,
+    actionScore: 76,
+    wealthScore: 80,
+    overallScore: 82,
+    members: [
+      { memberId: 1, name: '小李', bodyScore: 78, mindScore: 92, wisdomScore: 74, actionScore: 76, wealthScore: 80, overallScore: 82 }
+    ]
+  },
+  // 可切换成员列表(成员选择面板:本人 / 各成员 / 全家)
+  members: [
+    { memberId: 1, name: '小李', bodyScore: 78, mindScore: 92, wisdomScore: 74, actionScore: 76, wealthScore: 80, overallScore: 82 },
+    { memberId: 2, name: '爸爸', bodyScore: 70, mindScore: 88, wisdomScore: 79, actionScore: 82, wealthScore: 75, overallScore: 79 },
+    { memberId: 3, name: '妈妈', bodyScore: 82, mindScore: 85, wisdomScore: 81, actionScore: 78, wealthScore: 72, overallScore: 80 }
+  ],
+  // 维度中文名/五行(sortedDimensions 名称与元素来源;缺省时组件内 DIM_META 兜底)
+  dimensions: [
+    { code: 'body', name: '身', element: '土' },
+    { code: 'mind', name: '心', element: '火' },
+    { code: 'wisdom', name: '智', element: '金' },
+    { code: 'action', name: '行', element: '木' },
+    { code: 'wealth', name: '富', element: '水' }
+  ]
+}
+
+export default WUXING_SANDBOX_MOCK

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-0dc3d5e6a3b3e5e374467b8e0bc03e904a0e9f7d
+d8d3329b3f35089717ab0173465533e590755e1f

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1948",
+  "version": "1.0.1950",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1948",
+      "version": "1.0.1950",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1949",
+  "version": "1.0.1951",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 30 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,36 @@
 
 
 ---
 ---
 
 
+## v1.0.1951 (2026-11-01)
+
+### 测试
+- 补齐 family.familyMembers.addMember 契约级单测
+
+### 其他
+- 原为未覆盖 handler,本提交补 7 条契约级单测(MockMvc + 直接调用级):
+- - 正常路径:Service 返回 VO → code 200 + 用户有家庭触发 refreshFamilyGates
+- - 用户查询为空/无家庭:code 200 但不触发 refreshFamilyGates
+- - IllegalArgumentException / RuntimeException → code 500 + 异常信息透传
+- - JSON→AddFamilyMemberDTO 反序列化字段映射经 ArgumentCaptor 验证
+- - userId 空守卫(直接调用,@RequestAttribute required=true 无法注入 null)
+- 角色/权限口径:本 handler 无 @RequestAttribute(role/roles),授权由 Service 层
+- (familyId=登录用户所属家庭)与 JwtInterceptor(匿名注入 userId=-1 被 401 拦截)承担。
+- 不改 src/main 实现;测试 mock FamilyMemberService/UserMapper/UnlockGateService。
+- 7 用例全部通过(mvn -DskipTests=false -Dtest=FamilyMembersControllerAddMemberTest test)。
+- 
+
+
+## v1.0.1950 (2026-11-01)
+
+### 新功能
+- 登录前五维能量沙盘以 mock 数据展示完整星图
+
+### 其他
+- - 新增 utils/wuxing-sandbox-mock.js:登录前演示数据(心92 身78 智74 富80 行76 健康指数82,源自设计稿星图样例)
+- - 沙盘含维度条与成员切换(本人/爸爸/妈妈/全家),点顶点仍触发登录
+- 
+
+
 ## v1.0.1949 (2026-11-01)
 ## v1.0.1949 (2026-11-01)
 
 
 ### 测试
 ### 测试

+ 31 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1949
+> 当前版本: v1.0.1951
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,36 @@
 
 
 ---
 ---
 
 
+## v1.0.1951 (2026-11-01)
+
+### 测试
+- 补齐 family.familyMembers.addMember 契约级单测
+
+### 其他
+- 原为未覆盖 handler,本提交补 7 条契约级单测(MockMvc + 直接调用级):
+- - 正常路径:Service 返回 VO → code 200 + 用户有家庭触发 refreshFamilyGates
+- - 用户查询为空/无家庭:code 200 但不触发 refreshFamilyGates
+- - IllegalArgumentException / RuntimeException → code 500 + 异常信息透传
+- - JSON→AddFamilyMemberDTO 反序列化字段映射经 ArgumentCaptor 验证
+- - userId 空守卫(直接调用,@RequestAttribute required=true 无法注入 null)
+- 角色/权限口径:本 handler 无 @RequestAttribute(role/roles),授权由 Service 层
+- (familyId=登录用户所属家庭)与 JwtInterceptor(匿名注入 userId=-1 被 401 拦截)承担。
+- 不改 src/main 实现;测试 mock FamilyMemberService/UserMapper/UnlockGateService。
+- 7 用例全部通过(mvn -DskipTests=false -Dtest=FamilyMembersControllerAddMemberTest test)。
+- 
+
+
+## v1.0.1950 (2026-11-01)
+
+### 新功能
+- 登录前五维能量沙盘以 mock 数据展示完整星图
+
+### 其他
+- - 新增 utils/wuxing-sandbox-mock.js:登录前演示数据(心92 身78 智74 富80 行76 健康指数82,源自设计稿星图样例)
+- - 沙盘含维度条与成员切换(本人/爸爸/妈妈/全家),点顶点仍触发登录
+- 
+
+
 ## v1.0.1949 (2026-11-01)
 ## v1.0.1949 (2026-11-01)
 
 
 ### 测试
 ### 测试