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

fix: FamilyRelationGraph 完善 + login/family-dashboard 页面更新

User 4 месяцев назад
Родитель
Сommit
b981e34820

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -127,7 +127,11 @@ public class FamilyInviteController {
         }
 
         if (currentUser.getFamilyId() != null) {
-            return Result.error("您已加入家庭,无法重复加入");
+            if (currentUser.getFamilyId().equals(family.getId())) {
+                return Result.success("已在该家庭中");
+            }
+            familyInvitationService.mergeIntoFamily(userId, family.getId());
+            return Result.success("加入家庭成功(已合并原家庭)");
         }
 
         // 加入家庭

+ 21 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java

@@ -4,10 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.FamilyStatusDTO;
 import com.etotem.cfc.dto.InviteValidateDTO;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FamilyMembership;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyInvitation;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
 import com.etotem.cfc.mapper.FamilyInvitationMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -33,6 +35,9 @@ public class FamilyInvitationService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private FamilyMembershipMapper familyMembershipMapper;
+
     public InviteValidateDTO validateInvitation(String token) {
         FamilyInvitation invitation = familyInvitationMapper.selectOne(
                 new LambdaQueryWrapper<FamilyInvitation>()
@@ -158,12 +163,27 @@ public class FamilyInvitationService {
             familyMemberMapper.updateById(child);
         }
 
+        // 迁移家庭会员记录(付费等级等)
+        List<FamilyMembership> memberships = familyMembershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, oldFamilyId)
+        );
+        for (FamilyMembership ms : memberships) {
+            ms.setFamilyId(targetFamilyId);
+            familyMembershipMapper.updateById(ms);
+        }
+
+        // 只有在确实迁移到不同家庭时才检查并清理旧家庭
         if (!oldFamilyId.equals(targetFamilyId)) {
             Long memberCount = userMapper.selectCount(
                     new LambdaQueryWrapper<User>()
                             .eq(User::getFamilyId, oldFamilyId)
             );
-            if (memberCount == 0) {
+            Long fmCount = familyMemberMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getFamilyId, oldFamilyId)
+            );
+            if (memberCount == 0 && fmCount == 0) {
                 familyMapper.deleteById(oldFamilyId);
             }
         }

+ 22 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -51,8 +51,11 @@ private WechatService wechatService;
     @Resource
     private DistributionService distributionService;
 
-    @Resource
-    private OnboardingService onboardingService;
+@Resource
+private OnboardingService onboardingService;
+
+@Resource
+private FamilyInvitationService familyInvitationService;
 
     public LoginResultDTO wechatLogin(WechatLoginDTO dto) {
         // 模拟微信登录,实际需要调用微信API获取openid
@@ -659,6 +662,7 @@ private WechatService wechatService;
                 vo.setNickname(p.getNickname());
                 vo.setAvatar(p.getAvatar());
                 vo.setEffectiveRole("parent");
+                vo.setMemberType("parent");
                 vo.setRoleLabel(p.getFamilyRole() != null ? p.getFamilyRole() : "家长");
                 vo.setTotalPoints(p.getTotalPoints());
                 vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
@@ -676,6 +680,7 @@ private WechatService wechatService;
                 vo.setNickname(c.getNickname());
                 vo.setAvatar("");
                 vo.setEffectiveRole("child");
+                vo.setMemberType("child");
                 vo.setRoleLabel("孩子");
                 vo.setTotalPoints(c.getTotalPoints());
                 vo.setShowToFamily(c.getShowToFamily() != null ? c.getShowToFamily() : 1);
@@ -1042,9 +1047,13 @@ private WechatService wechatService;
         if (user == null) {
             throw new RuntimeException("用户不存在");
         }
-        user.setFamilyId(family.getId());
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
+        if (user.getFamilyId() != null && !user.getFamilyId().equals(family.getId())) {
+            familyInvitationService.mergeIntoFamily(userId, family.getId());
+        } else if (user.getFamilyId() == null) {
+            user.setFamilyId(family.getId());
+            user.setUpdatedAt(new Date());
+            userMapper.updateById(user);
+        }
         
         // 如果家庭绑定了成长规划师,自动将家庭ID添加到成长规划师的 teacherFamilyIds
         if (family.getTeacherId() != null) {
@@ -1152,11 +1161,15 @@ private WechatService wechatService;
                 throw new RuntimeException("该账号不允许登录小程序");
             }
 
-            // 手机号已注册,加入家庭
+            // 手机号已注册,加入家庭——如有旧家庭则合并
             user = existingUser;
-            user.setFamilyId(family.getId());
-            user.setUpdatedAt(new Date());
-            userMapper.updateById(user);
+            if (user.getFamilyId() != null && !user.getFamilyId().equals(family.getId())) {
+                familyInvitationService.mergeIntoFamily(user.getId(), family.getId());
+            } else {
+                user.setFamilyId(family.getId());
+                user.setUpdatedAt(new Date());
+                userMapper.updateById(user);
+            }
         } else {
             // 创建新用户并加入家庭
             isNewUser = true;

+ 7 - 7
cfc-backend/src/main/resources/application.yml

@@ -66,13 +66,13 @@ zkxz:
 
 wechat:
   appid: wx5ba8038ef16fb245
-  secret: YOUR_APP_SECRET_HERE
-  mch-id: YOUR_MCH_ID
-  mch-key: YOUR_MCH_KEY
-  api-v3-key: YOUR_API_V3_KEY
-  mch-serial-no: YOUR_MCH_SERIAL_NO
-  private-key-path: /path/to/apiclient_key.pem
-  notify-url: http://your-domain.com/api/payment/notify
+  secret: 3dae525c6f5e010cc034f59abbd1b1cc
+  mch-id: 1703255039
+  mch-key: wx5ba8038ef16fb245wx5ba8038ef16f
+  api-v3-key: wx5ba8038ef16fb245wx5ba8038ef16f
+  mch-serial-no: 53C2A906094D725BC350A3414AADCA6D79882AAF
+  private-key-path: cert/apiclient_key.pem
+  notify-url: http://cfc.bianwoyou.com.cn/api/payment/notify
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true  # 测试模式:跳过微信API调用,使用模拟数据

BIN
cfc-backend/src/main/resources/cert/apiclient_cert.p12


+ 25 - 0
cfc-backend/src/main/resources/cert/apiclient_cert.pem

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEKzCCAxOgAwIBAgIUU8KpBglNclvDUKNBSq3KbXmIKq8wDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjYwNzA3MDQzNTI0WhcNMzEwNzA2MDQzNTI0WjCBhDETMBEGA1UEAwwK
+MTcwMzI1NTAzOTEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL
+DCfkur/lm77ohb7vvIjljJfkuqzvvInnp5HmioDmnInpmZDlhazlj7gxCzAJBgNV
+BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBALLlSO3+wUqx71uBjygD3qGNpkCCBJgnPnVKDfd+gnDJen7YjzU+
+wyWm26AYH1TDwcnj7zwhO9AmqdEEQfpqD7peS99n0epKNwSvEtipk56rL+BOl5TW
+MYQVW/YTkaXSAqpdiEmMWqHASN6Vb247w6GOCEtNfsFaHNS5DnITbdEl1aiQot2n
+//JyvZGVZcBtXEMmDOrpyZ0zmcXP8lOPys0xcZV+HvxhfPX87L3qDrnQV/+KMKpI
+/RWmwqUHCOMt/jG1Kejd7Wr3jgWV3zXY4TmFhACHbIsw/wK6PO3jHr+F+twJIY0c
+Ji4vfni/0wF8wEpt7EeLm+u5SzBd+rQClJECAwEAAaOBuTCBtjAJBgNVHRMEAjAA
+MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2
+Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD
+MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC
+MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQBA
+vlQc2+az9H6AWkmdMZjlJYulCUQblAh6xQEsU7yiL1CLkUlnb9DYBqyKqif3gk0W
+oyTFhWAC3kN1tecYUyOFJ/7ytd3z4941/O30OvyIziHppO8E7sEU4Cdw1cQasSTu
+o7JtpATxQ9JLZJShn5EUxQ1r8+VHknkYudqOtB8iP9hGn3ftOep6q3bxwr+//VOR
+Szz8YwhCeqyUhF9+RazeEJs735rrHdtI+hQ6XAGtgZGYhuVtVvOWX0IuDDYBVu2k
+8HvuP6xYorq8Ilq7IOL3bTG1s0iRuC1j95x6xrkj6FMQuNI9nCg1Sg4tnrvTMZY9
+0QDK/Do18EKgk8T0xYYp
+-----END CERTIFICATE-----

+ 28 - 0
cfc-backend/src/main/resources/cert/apiclient_key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCy5Ujt/sFKse9b
+gY8oA96hjaZAggSYJz51Sg33foJwyXp+2I81PsMlptugGB9Uw8HJ4+88ITvQJqnR
+BEH6ag+6XkvfZ9HqSjcErxLYqZOeqy/gTpeU1jGEFVv2E5Gl0gKqXYhJjFqhwEje
+lW9uO8OhjghLTX7BWhzUuQ5yE23RJdWokKLdp//ycr2RlWXAbVxDJgzq6cmdM5nF
+z/JTj8rNMXGVfh78YXz1/Oy96g650Ff/ijCqSP0VpsKlBwjjLf4xtSno3e1q944F
+ld812OE5hYQAh2yLMP8Cujzt4x6/hfrcCSGNHCYuL354v9MBfMBKbexHi5vruUsw
+Xfq0ApSRAgMBAAECggEAJSBih0nkkXzmAOadcB+Fd5+vDhotm+ohEzRgO6ncpk2a
+B3MoHVO+KjU7xTjcX7oUoLKWNrNq92LewUsR6kdSpknlkyK3tNjcHDO9RIvE4BwE
+3pdQ7Amb0x6frC0ZgAFankK1AxaGxKTkOpVmv8jVa9TPMdmOYdAyxY72SYRwuQO4
+0YbNCtCfnn8iLhHNpEui1AA8WQP9IIWXCqMlKH6Q8xnwKUP4c1ey3N39NpdGdxoV
+SDbSBVWfsoB6sT3Tj2ef+8g0HlzC+hkKd/gfPq5Z015936mdeBv5BDiHA+mYGTRx
+TMkwpcpJ9YxJy1AS0D190WbjAqymGMSisjC7I/3oAQKBgQDstRMOGeTqMDsEzEXh
+pNkETHyhO9rvXCIjgoBti4iMQWWppkWdPtA27rjbRYsqz9QCfxoQXDQ3fNYqjSd0
+oeuK7mUj3/dky0vdKsBhZ5jNBPmud6+4htM8UTf9r4b41i8Rp02b3ymDzeg6pg+4
+8NH+ZPqz3AMgGRZw65GXcoE+EQKBgQDBefaPJOP/K3nLJ/oDMWht4Rv3NSjTDfPc
+6iWHwxVQnyDjyZLvCOjjA2QtZC8jL9yq/H4pwDTc7xxsxRpFMpMm/OoMDBVunTk0
+MhBACPumoMSAxohKQrc4RY20jcJVVAg0wPeTU27r76RaUq5UhVIpFM/pZOU0tHNR
+ZbqmqcdugQKBgQCWj97c+0+GPsNrm9tfWZHIkDY+AGqUEdCNMbcq9rQWMorNzDs/
+d/LM3i+rGaMZyjQammojueJk4P0A9+P+5feC2Xxvw0gDTuSke0r/KfwhfGasuOiG
+wQuftDGfJYT+xo4N9pMA8IPhmJl8pKAfjN6lp+lneixqwEpr7Qsgf+8BYQKBgF+h
+83dp2rCkQr1iczfk0iON447b3eQEv4dwzUA/82c4YL93QYOZudOMN5YgMRSJcd+q
+kon8kaSrJDe3bXbKuuXImb9+sZFNcZsVK+cbCZihWr/0YiWiuIG+D38xmt8mfC/n
+Ofi+gtNoTqq+t6DbjXL08JgC4TCeCWowiIQc45EBAoGAeR5vg1r3eLsD3UyILMvL
+u9cCIYXWI9sgTgyd5P1M3NK4mzpzZABPE22rmooA3ql8/dMwoew9KvBqzeTKOZLq
+Uh3fxP1fNV33qKMMhwD9d1hQ5AKWHQOOTDGOgh9Fb8HRQpgCIQkUWnONgjuCPVRw
+7lyq7B9bB4f3qgxdvznsoJk=
+-----END PRIVATE KEY-----

+ 3 - 6
cfc-frontend/components/DimensionProducts.vue

@@ -11,12 +11,9 @@
       <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="$emit('productClick', prod)">
         <image class="product-img" :src="prod.coverImage || '/static/default-product.png'" mode="aspectFill" />
         <text class="product-name line-clamp-2">{{ prod.name }}</text>
-        <text class="product-price" v-if="!isLoggedIn">登录查看</text>
-        <template v-else>
-          <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
-          <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
-          <text class="product-price" v-else>免费</text>
-        </template>
+        <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
+        <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
+        <text class="product-price" v-else>免费</text>
       </view>
     </view>
   </view>

+ 6 - 6
cfc-frontend/components/FamilyRelationGraph.vue

@@ -91,10 +91,6 @@
       </template>
     </view>
   </view>
-  <!-- 1人空状态(仅自己) -->
-  <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 1">
-    <text class="empty-text">暂无其他家庭成员</text>
-  </view>
   <!-- 0人空状态 -->
   <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
     <text class="empty-text">暂无家庭成员</text>
@@ -177,9 +173,11 @@ export default {
       if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
       var energy = this.getEnergy(found.id)
       var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
+      var nick = found.nickname || found.name || '我'
+      if (nick === '用户') nick = '我'
       return {
         id: found.id,
-        nickname: found.nickname || found.name || '我',
+        nickname: nick,
         memberType: found.memberType || 'child',
         isSelf: true,
         displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
@@ -193,9 +191,11 @@ export default {
         .map(function(m) {
           var energy = self.getEnergy(m.id)
           var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
+          var nick = m.nickname || m.name || '成员'
+          if (nick === '用户') nick = '成员'
           return {
             id: m.id,
-            nickname: m.nickname || m.name || '成员',
+            nickname: nick,
             memberType: m.memberType || 'child',
             isSelf: false,
             displayName: (m.nickname && m.nickname.charAt(0)) || (m.name && m.name.charAt(0)) || '?',

+ 6 - 3
cfc-frontend/pages/action/index.vue

@@ -44,9 +44,9 @@
 
     <!-- 关系图谱(登录后可见) — 家庭关系网络 -->
     <FamilyRelationGraph
-      v-if="isLoggedIn"
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
       dimensionCode="action"
-      :selfId="currentChildId"
+      :selfId="activeChildId"
       :members="familyMembersVisible"
       :energyMap="energyMapForGraph"
       :intimacyMap="intimacyMapForGraph"
@@ -147,6 +147,7 @@ export default {
       isLoggedIn: false,
       role: null,
       currentChildId: null,
+      activeChildId: null,
       currentChildName: '',
       userName: '',
       children: [],
@@ -257,6 +258,7 @@ export default {
           for (var i = 0; i < self.children.length; i++) {
             if (self.children[i].childId === savedChildId) {
               self.currentChildId = savedChildId
+              self.activeChildId = savedChildId
               self.currentChildName = self.children[i].name || self.children[i].nickname || '孩子'
               found = true
               break
@@ -264,8 +266,9 @@ export default {
           }
           if (!found && self.children.length > 0) {
             self.currentChildId = self.children[0].childId
+            self.activeChildId = self.children[0].childId
             self.currentChildName = self.children[0].name || self.children[0].nickname || '孩子'
-            uni.setStorageSync('currentChildId', self.currentChildId)
+            uni.setStorageSync('currentChildId', self.activeChildId)
           }
           if (self.currentChildId) {
             self.loadDimensionData()

+ 2 - 0
cfc-frontend/pages/invite/join.vue

@@ -208,6 +208,7 @@ export default {
           uni.setStorageSync('userId', data.userId)
           uni.setStorageSync('role', data.role)
           uni.setStorageSync('familyId', data.familyId)
+          uni.setStorageSync('currentFamilyId', data.familyId)
           uni.setStorageSync('currentRole', data.role)
           uni.setStorageSync('isSwitchedChild', false)
           uni.setStorageSync('isSwitchedTeacher', false)
@@ -247,6 +248,7 @@ export default {
           this.accepted = true
           // 更新缓存的 familyId
           uni.setStorageSync('familyId', this.inviteInfo.familyId)
+          uni.setStorageSync('currentFamilyId', this.inviteInfo.familyId)
           uni.showToast({ title: '加入家庭成功', icon: 'success' })
         } else {
           uni.showToast({ title: res.message || '加入失败', icon: 'none' })

+ 3 - 1
cfc-frontend/pages/login/login.vue

@@ -126,6 +126,7 @@ export default {
           uni.setStorageSync('userId', res.data.userId)
           uni.setStorageSync('role', res.data.role)
           uni.setStorageSync('familyId', res.data.familyId)
+          uni.setStorageSync('currentFamilyId', res.data.familyId)
           uni.setStorageSync('currentRole', res.data.role)
           uni.setStorageSync('isSwitchedChild', false)
           uni.setStorageSync('isSwitchedTeacher', false)
@@ -134,7 +135,8 @@ export default {
           }
           uni.setStorageSync('userInfo', {
             nickname: res.data.nickname,
-            userId: res.data.userId
+            userId: res.data.userId,
+            familyId: res.data.familyId
           })
           if (res.data.teacherStatus) {
             uni.setStorageSync('teacherStatus', res.data.teacherStatus)

+ 3 - 0
cfc-frontend/pages/mind-detail/family-dashboard.vue

@@ -161,6 +161,9 @@ export default {
 
   onLoad() {
     this.familyId = uni.getStorageSync('currentFamilyId')
+    if (!this.familyId) {
+      this.familyId = uni.getStorageSync('familyId')
+    }
     if (!this.familyId) {
       // Try to get from user info
       var userInfo = uni.getStorageSync('userInfo')

+ 334 - 0
docs/superpowers/specs/2026-07-07-家庭成员关系视角转换设计.md

@@ -0,0 +1,334 @@
+# 家庭成员关系转换需求设计
+
+> **文档版本**: v1.0
+> **状态**: 已完成(代码已实现)
+> **日期**: 2026-07-07
+> **功能名称**: 家庭成员关系视角转换 / 相对关系标签
+
+---
+
+## 一、需求概述
+
+### 1.1 业务背景
+
+同一个家庭中,不同身份的用户看到的"我"和"其他成员的关系标签"不同。例如:
+
+| 登录身份 | 家庭成员关系显示 |
+|---------|----------------|
+| 儿子(child)登录 | **我** - 爸爸 - 妈妈 |
+| 爸爸(parent)登录 | **我** - 儿子 - 老婆 |
+| 女儿登录,排行老大 | **我** - 哥哥 - 弟弟 - 爸爸 - 妈妈 |
+| 妈妈登录 | **我** - 儿子 - 女儿 - 爸爸 |
+
+系统中所有展示家庭成员关系的地方(家庭关系图谱、成员列表、家庭能量条等),关系标签都应该基于**当前登录用户的视角**动态计算。
+
+### 1.2 目标
+
+- 不同身份登录时,同一成员显示不同的关系标签
+- 标签符合中文亲属称谓习惯
+- 支持多子女家庭的长幼排序(长子、次子、三子...)
+- 支持兄弟姐妹关系(按年龄区分哥哥/姐姐/弟弟/妹妹)
+- 支持配偶关系(丈夫/妻子)
+
+---
+
+## 二、业务规则
+
+### 2.1 关系标签计算规则
+
+| 查看者角色 | 被查看者角色 | 性别 | 关系标签 |
+|-----------|------------|------|---------|
+| **自己** | — | — | `我` |
+| parent | child | male | `儿子`(独子女)或 `长子`/`次子`/`三子`...(多子女) |
+| parent | child | female | `女儿`(独子女)或 `长女`/`次女`/`三女`...(多子女) |
+| parent | child | 无 | `孩子` |
+| child | parent | male | `父亲` |
+| child | parent | female | `母亲` |
+| child | parent | 无 | `父亲` |
+| child | child(弟妹) | male | `弟弟` |
+| child | child(弟妹) | female | `妹妹` |
+| child | child(兄姐) | male | `哥哥` |
+| child | child(兄姐) | female | `姐姐` |
+| child | child(同龄) | male | `哥哥`(默认) |
+| child | child(同龄) | female | `姐姐`(默认) |
+| child | child(无性别) | — | `孩子` |
+| parent | parent(配偶) | male | `丈夫` |
+| parent | parent(配偶) | female | `妻子` |
+| parent | parent(配偶) | 无 | `配偶` |
+
+### 2.2 多子女排序规则
+
+1. 所有子女按**生日从小到大**排序(年长在前)
+2. 排序后按序号赋予前缀:`长`、`次`、`三`、`四`、`五`、`六`、`七`、`八`、`九`、`十`
+3. 超过十子女时使用数字:`第11`、`第12`...
+4. 例:三个儿子(生日依次为 2015/2018/2020年)→ `长子`、`次子`、`三子`
+
+### 2.3 兄弟姐妹年龄判断规则
+
+基于双方生日判断:
+- 查看者生日 > 被查看者生日 → 被查看者是**弟弟/妹妹**
+- 查看者生日 < 被查看者生日 → 被查看者是**哥哥/姐姐**
+- 生日相同 → 默认**哥哥/姐姐**
+- 无生日数据 → 默认**哥哥/姐姐**
+
+---
+
+## 三、技术实现
+
+### 3.1 核心方法
+
+**位置**: `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java`
+
+```java
+private String computeRelativeLabel(FamilyMember viewer, FamilyMember target, List<FamilyMember> allMembers)
+```
+
+**参数说明**:
+- `viewer`: 当前登录用户对应的家庭成员记录
+- `target`: 被查看的家庭成员记录
+- `allMembers`: 该家庭全部成员列表(用于多子女排序)
+
+**返回值**: 关系标签字符串("我"/"儿子"/"父亲"/"哥哥" 等)
+
+### 3.2 调用时机
+
+在 `listMembers()` 方法中,为每个 `FamilyMemberVO` 计算 `relativeLabel`:
+
+```java
+private List<FamilyMemberVO> toFamilyMemberVOList(List<FamilyMember> members, FamilyMember viewerMember) {
+    // ...
+    for (FamilyMember fm : members) {
+        FamilyMemberVO vo = toFamilyMemberVO(fm);
+        // 设置视角相关的关系标签
+        if (viewerMember != null) {
+            vo.setRelativeLabel(computeRelativeLabel(viewerMember, fm, members));
+        }
+        result.add(vo);
+    }
+    return result;
+}
+```
+
+### 3.3 数据模型
+
+**FamilyMemberVO.java** 相关字段:
+
+```java
+public class FamilyMemberVO {
+    // ...其他字段...
+
+    /** 视角相关的关系标签(如 "我"/"儿子"/"女儿"/"父亲"/"母亲"/"哥哥"/"姐姐") */
+    private String relativeLabel;
+
+    /** 计算后的实际角色(parent/child) */
+    private String effectiveRole;
+
+    /** 角色标签显示文本(家长/孩子/配偶/父母等) */
+    private String roleLabel;
+}
+```
+
+### 3.4 角色计算逻辑
+
+`computeEffectiveRole()` 决定用户是 parent 还是 child:
+
+```java
+public String computeEffectiveRole(FamilyMember member) {
+    // 1. roleOverride 非 auto → 直接返回
+    if (member.getRoleOverride() != null && !"auto".equals(member.getRoleOverride())) {
+        return member.getRoleOverride();
+    }
+    // 2. 取关系类型模板值
+    RelationshipType relType = relationshipTypeMapper.selectOne(
+        new LambdaQueryWrapper<RelationshipType>()
+            .eq(RelationshipType::getTypeKey, member.getRelationshipType()));
+    if (relType != null && relType.getDefaultRole() != null) {
+        return relType.getDefaultRole();
+    }
+    // 3. 按年龄计算
+    if (member.getBirthday() != null) {
+        int age = calculateAge(member.getBirthday());
+        int threshold = systemConfigService.getChildAgeThreshold(); // 默认18岁
+        if (age < threshold) return "child";
+    }
+    return "parent";
+}
+```
+
+### 3.5 API 响应
+
+`POST /api/family/member/list` 返回:
+
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "id": 1,
+      "nickname": "小明",
+      "relationshipType": "son",
+      "relationshipTypeName": "儿子",
+      "effectiveRole": "child",
+      "roleLabel": "孩子",
+      "relativeLabel": "我",
+      "gender": "male",
+      "age": 12,
+      "birthday": "2013-05-01"
+    },
+    {
+      "id": 2,
+      "nickname": "张先生",
+      "relationshipType": "father",
+      "relationshipTypeName": "父亲",
+      "effectiveRole": "parent",
+      "roleLabel": "家长",
+      "relativeLabel": "爸爸",
+      "gender": "male",
+      "age": 40
+    },
+    {
+      "id": 3,
+      "nickname": "李女士",
+      "relationshipType": "mother",
+      "relationshipTypeName": "母亲",
+      "effectiveRole": "parent",
+      "roleLabel": "家长",
+      "relativeLabel": "妈妈",
+      "gender": "female",
+      "age": 38
+    }
+  ]
+}
+```
+
+---
+
+## 四、字段说明
+
+| 字段 | 来源 | 说明 |
+|------|------|------|
+| `relativeLabel` | `computeRelativeLabel()` 计算 | **视角相关**关系标签,不同查看者看到不同值 |
+| `relationshipTypeName` | `relationship_types` 表 | **绝对**关系名称,不随查看者变化 |
+| `effectiveRole` | `computeEffectiveRole()` 计算 | 角色(parent/child),用于业务判断 |
+| `roleLabel` | 根据 effectiveRole 推断 | 角色展示文本(家长/孩子) |
+
+### 4.1 字段区别
+
+```
+场景:爸爸(id=2)登录,查看儿子(id=1)
+
+字段对比:
+- relativeLabel = "儿子"     ← 爸爸看儿子 → 儿子标签
+- relationshipTypeName = "儿子"  ← 固定关系名(儿子)
+- effectiveRole = "child"     ← 儿子的角色
+- roleLabel = "孩子"           ← 角色文本
+```
+
+```
+场景:儿子(id=1)登录,查看爸爸(id=2)
+
+字段对比:
+- relativeLabel = "爸爸"     ← 儿子看爸爸 → 父亲标签(口语化)
+- relationshipTypeName = "父亲" ← 固定关系名
+- effectiveRole = "parent"    ← 爸爸的角色
+- roleLabel = "家长"           ← 角色文本
+```
+
+---
+
+## 五、使用场景
+
+### 5.1 家庭关系图谱(FamilyRelationGraph.vue)
+
+Canvas 节点显示关系标签:
+- 当前用户节点:显示"我"
+- 其他成员节点:显示 `relativeLabel`
+
+### 5.2 家庭成员列表(FamilyMembers.vue)
+
+成员卡片展示关系标签:
+- 替换原有的 relationshipTypeName
+- 提升用户归属感("爸爸"比"父亲"更亲切)
+
+### 5.3 家庭能量条(FamilyEnergyBar)
+
+能量沙盘展示各成员时,使用 `relativeLabel` 标注成员身份。
+
+### 5.4 任务相关
+
+- 创建任务时选择执行者,显示 `relativeLabel` 而非 `nickname`
+- 任务通知中提及成员时使用 `relativeLabel`
+
+---
+
+## 六、边界情况处理
+
+| 情况 | 处理方式 |
+|------|---------|
+| 查看者与被查看者是同一人 | 返回 `我` |
+| 被查看者无性别数据 + child 角色 | 默认 `孩子` |
+| 被查看者无性别数据 + parent 角色 | 默认 `父亲`(parent→parent 配偶情况默认 `配偶`) |
+| 无生日数据无法判断长幼(同龄) | 默认显示 `哥哥`/`姐姐` |
+| 子女数量超过10个 | 使用 `第11`、`第12` 等数字前缀 |
+| viewerMember 为 null(未登录) | 不计算 `relativeLabel`,返回 null |
+| 被查看者无角色(effectiveRole 为空) | fallback 返回 `roleLabel` 或 null |
+
+---
+
+## 七、数据依赖
+
+| 依赖项 | 说明 | 状态 |
+|--------|------|------|
+| `family_members` 表 | 成员关系数据 | ✅ |
+| `relationship_types` 表 | 关系类型字典 | ✅ |
+| `system_config` 表 | child_age_threshold(未成年年龄分界线) | ✅ |
+| `FamilyMemberService.computeEffectiveRole()` | 角色计算 | ✅ |
+| `FamilyMemberService.computeRelativeLabel()` | 关系标签计算 | ✅ |
+
+---
+
+## 八、相关文件清单
+
+| 文件路径 | 操作 | 说明 |
+|---------|------|------|
+| `cfc-backend/.../service/FamilyMemberService.java` | 已实现 | `computeRelativeLabel()` 方法(419-517行) |
+| `cfc-backend/.../dto/FamilyMemberVO.java` | 已实现 | `relativeLabel` 字段(46-47行) |
+| `cfc-backend/.../service/FamilyMemberService.java` | 已实现 | `toFamilyMemberVOList()` 调用处(171-175行) |
+| `cfc-backend/.../service/FamilyMemberService.java` | 已实现 | `computeEffectiveRole()` 方法(384-417行) |
+| `cfc-backend/.../entity/FamilyMember.java` | 已存在 | 成员实体 |
+| `cfc-backend/.../entity/RelationshipType.java` | 已存在 | 关系类型实体 |
+
+---
+
+## 九、验收标准
+
+### 9.1 功能验收
+
+- [ ] 爸爸登录:看到所有子女的关系标签为"儿子"/"女儿"(或"长子"等)
+- [ ] 妈妈登录:看到所有子女的关系标签为"儿子"/"女儿"
+- [ ] 儿子登录:看到爸爸的关系标签为"爸爸"(而非"父亲")
+- [ ] 儿子登录:看到妈妈的关系标签为"妈妈"(而非"母亲")
+- [ ] 儿子登录:看到兄弟姐妹时,按年龄正确显示"哥哥"/"姐姐"/"弟弟"/"妹妹"
+- [ ] 子女超过3个时,正确显示"长子"、"次子"、"三子"
+- [ ] parent 看到配偶的关系标签为"丈夫"/"妻子"
+- [ ] 当前用户看到自己的标签为"我"
+
+### 9.2 数据一致性
+
+- [ ] `relativeLabel` 与 `effectiveRole`、`gender` 组合逻辑一致
+- [ ] 多子女排序按生日,不按 ID 或添加顺序
+- [ ] 无性别数据时有合理的 fallback 值
+
+### 9.3 性能
+
+- [ ] `computeRelativeLabel` 为纯内存计算,无额外数据库查询
+- [ ] listMembers 批量返回时无 N+1 查询问题
+
+---
+
+## 十、后续优化方向
+
+1. **关系反向映射**:目前 parent→child 显示"儿子"/"女儿",未来可配置为显示"我"(如"爸爸的宝贝")
+2. **多语言支持**:export 模式时 relocations 可翻译为 "son"/"daughter"/"father"/"mother"
+3. **自定义称谓**:允许用户设置自己喜欢的称谓(如"老爸"vs"爸爸")
+4. **干父母/继父母等特殊关系**:当前规则未覆盖,可扩展 `RelationshipType` 表