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

fix(family): 编辑资料改辈分后重建关系表,修复关系标签不刷新(R-4)

- FamilyRelationshipService 新增 rebuildRelationsForMember:快照双向关系质量评分(信任/亲密/沟通)
  → 删旧关系 → 按绝对辈分/同辈类型/锚点重建 → 按 from→to 回填评分,避免重建把问卷评分清零
- FamilyMemberService.updateMember 辈分变更后调用重建(锚点=操作者成员);操作者即本人时不写 spouse
- 重建失败整事务回滚,杜绝「辈分已改、关系未变」中间态
- 规格 §10 R-3/R-4 同步为已修复并记录锚点与失败策略
Agnes Agent 5 дней назад
Родитель
Сommit
5bd559b6f9

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -1045,18 +1045,52 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             }
             }
         }
         }
         // 管理员可更新辈分关系
         // 管理员可更新辈分关系
+        boolean identityChanged = false;
+        String rebuildPeerType = null;
         if (isAdmin && dto.getGenerationLevel() != null && !dto.getGenerationLevel().trim().isEmpty()) {
         if (isAdmin && dto.getGenerationLevel() != null && !dto.getGenerationLevel().trim().isEmpty()) {
             GenerationLevel genLevel = GenerationLevel.fromValue(dto.getGenerationLevel());
             GenerationLevel genLevel = GenerationLevel.fromValue(dto.getGenerationLevel());
             if (genLevel != null) {
             if (genLevel != null) {
+                // 本人自编辑不设「配偶」:关系是相对的,配偶只能由对方(或管理员代设)指定,
+                // 自己给自己标配偶会产生「我与我配偶」的歧义关系行。
+                boolean selfEdit = member.getUserId() != null && member.getUserId().equals(userId);
                 member.setGeneration(genLevel.getOffset());
                 member.setGeneration(genLevel.getOffset());
-                boolean isSpouse = genLevel == GenerationLevel.PEER && "spouse".equals(dto.getPeerType());
+                boolean isSpouse = genLevel == GenerationLevel.PEER
+                        && "spouse".equals(dto.getPeerType()) && !selfEdit;
                 member.setIsSpouse(isSpouse);
                 member.setIsSpouse(isSpouse);
+                identityChanged = true;
+                rebuildPeerType = genLevel == GenerationLevel.PEER
+                        ? (isSpouse ? "spouse" : "sibling")
+                        : null;
             }
             }
         }
         }
         // 孩子身份标记(仅家庭管理员可设置;落库到 users 表对应记录)
         // 孩子身份标记(仅家庭管理员可设置;落库到 users 表对应记录)
         applyIsChildFlag(userId, user.getFamilyId(), member.getUserId(), dto.getIsChild());
         applyIsChildFlag(userId, user.getFamilyId(), member.getUserId(), dto.getIsChild());
         member.setUpdatedAt(new Date());
         member.setUpdatedAt(new Date());
         familyMemberMapper.updateById(member);
         familyMemberMapper.updateById(member);
+
+        // 辈分/同辈类型变更后重建关系表:成员列表的关系标签优先读 family_relationships,
+        // 不重建会出现「编辑资料改了身份、列表标签不刷新」(规格 §10 R-4)。
+        // 锚点=操作者(编辑人)成员记录:管理员给某成员选「配偶」时,指 TA 与操作者是配偶;
+        // 本人自编辑时锚点为 null(不写 spouse)。
+        if (identityChanged) {
+            Long anchorMemberId = null;
+            boolean selfEdit = member.getUserId() != null && member.getUserId().equals(userId);
+            if (!selfEdit) {
+                FamilyMember operator = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, member.getFamilyId())
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+                anchorMemberId = operator != null ? operator.getId() : null;
+            }
+            try {
+                familyRelationshipService.rebuildRelationsForMember(
+                        member.getFamilyId(), member.getId(), member.getGeneration(), rebuildPeerType, anchorMemberId);
+            } catch (RuntimeException e) {
+                // 重建失败必须整体回滚:否则辈分已改而关系未重建,正是 R-4 要消除的不一致状态
+                log.error("重建成员关系失败,回滚本次资料更新: memberId={}, error={}", member.getId(), e.getMessage());
+                throw e;
+            }
+        }
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
         return toFamilyMemberVO(member);
         return toFamilyMemberVO(member);
     }
     }

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyRelationshipService.java

@@ -11,7 +11,9 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
@@ -202,4 +204,63 @@ public class FamilyRelationshipService {
         }
         }
         return computeRelationshipLabel(generationValue, gender);
         return computeRelationshipLabel(generationValue, gender);
     }
     }
+
+    /**
+     * 重建某个成员的全部关系(用于「编辑资料」修改辈分/同辈类型,《规格 §10 R-4》:
+     * 原先只改 family_members.generation 不重建 family_relationships,导致成员列表标签
+     * 仍读旧关系而不刷新)。
+     *
+     * <p>实现要点:先按 (from→to) 快照双向行的关系质量评分(信任/亲密/沟通,来自
+     * relationship-questionnaire 的答卷人视角),再删旧建新,最后按同一方向回填评分,
+     * 避免重建把历史评分清零。</p>
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void rebuildRelationsForMember(Long familyId, Long memberId, Integer newMemberGeneration,
+                                          String peerType, Long anchorMemberId) {
+        if (familyId == null || memberId == null) {
+            return;
+        }
+        List<FamilyRelationship> oldRows = selectRelationsTouching(memberId);
+        Map<String, FamilyRelationship> scoreSnapshot = new HashMap<>();
+        for (FamilyRelationship r : oldRows) {
+            boolean hasScore = r.getTrustScore() != null
+                    || r.getIntimacyScore() != null
+                    || r.getCommunicationScore() != null;
+            if (hasScore) {
+                scoreSnapshot.put(r.getFromMemberId() + "->" + r.getToMemberId(), r);
+            }
+        }
+
+        deleteRelationsForMember(memberId);
+        createRelationsForNewMember(familyId, memberId, newMemberGeneration, peerType, anchorMemberId);
+
+        if (scoreSnapshot.isEmpty()) {
+            return;
+        }
+        List<FamilyRelationship> freshRows = selectRelationsTouching(memberId);
+        for (FamilyRelationship r : freshRows) {
+            FamilyRelationship old = scoreSnapshot.get(r.getFromMemberId() + "->" + r.getToMemberId());
+            if (old == null) {
+                continue;
+            }
+            r.setTrustScore(old.getTrustScore());
+            r.setIntimacyScore(old.getIntimacyScore());
+            r.setCommunicationScore(old.getCommunicationScore());
+            try {
+                familyRelationshipMapper.updateById(r);
+            } catch (Exception e) {
+                log.warn("关系评分回填失败(不阻断): relId={}, error={}", r.getId(), e.getMessage());
+            }
+        }
+    }
+
+    /** 查询所有「以该成员为起点或终点」的关系行(双向) */
+    private List<FamilyRelationship> selectRelationsTouching(Long memberId) {
+        List<FamilyRelationship> rows = familyRelationshipMapper.selectList(
+                new LambdaQueryWrapper<FamilyRelationship>()
+                        .and(w -> w.eq(FamilyRelationship::getFromMemberId, memberId)
+                                .or()
+                                .eq(FamilyRelationship::getToMemberId, memberId)));
+        return rows != null ? new ArrayList<>(rows) : new ArrayList<>();
+    }
 }
 }

+ 4 - 2
docs/superpowers/specs/2026-11-07-family-join-dynamic-identity.md

@@ -180,5 +180,7 @@ export const approveJoinRequest = (requestId, comment, generationLevel, peerType
 
 
 - **R-1 辈分锚点漂移**:`FamilyMemberService.addMember` 原先把 `genLevel.getOffset()`(相对偏移)传给「绝对辈分」参数,关联成员辈分非 0 时结果错误。**已在本次修复**:改传已算好的绝对辈分 `generation`,并补传 `peerType` 与锚点 `relative.getId()`。
 - **R-1 辈分锚点漂移**:`FamilyMemberService.addMember` 原先把 `genLevel.getOffset()`(相对偏移)传给「绝对辈分」参数,关联成员辈分非 0 时结果错误。**已在本次修复**:改传已算好的绝对辈分 `generation`,并补传 `peerType` 与锚点 `relative.getId()`。
 - **R-2 配偶唯一性**:`relation_type='spouse'` 不限制唯一性,同一人仍可与多个同辈写 spouse(例如分两次加入两位同辈配偶)。**配偶仅对锚点成员生效**,锚点外同辈一律 `sibling`;产品侧不额外校验一夫一妻。
 - **R-2 配偶唯一性**:`relation_type='spouse'` 不限制唯一性,同一人仍可与多个同辈写 spouse(例如分两次加入两位同辈配偶)。**配偶仅对锚点成员生效**,锚点外同辈一律 `sibling`;产品侧不额外校验一夫一妻。
-- **R-3 历史成员**:既有家庭成员的 `relation_type` 为默认 `lineal`,配偶仍显示「兄弟/姐妹」,需用户重新「编辑资料」选配偶后修正(且受 R-4 制约,见下)。
-- **R-4 编辑资料不重建关系(既有缺陷,本次不修)**:`FamilyMemberService.updateMember` 改辈分时只更新 `member.generation`/`isSpouse`,既不重建 `family_relationships`、也不写 `relation_type`;而成员列表标签优先读关系表,故「编辑资料」改身份后标签不刷新。修复方案(重建关系 + 锚点取操作者成员)另立任务。
+- **R-3 历史成员**:既有家庭成员的 `relation_type` 为默认 `lineal`,配偶仍显示「兄弟/姐妹」,需用户重新「编辑资料」选配偶后修正(R-4 修复后该操作即可生效)。
+- **R-4 编辑资料不重建关系 —— 已修复**:`FamilyMemberService.updateMember` 改辈分时原只更新 `member.generation`/`isSpouse`,不重建 `family_relationships`、不写 `relation_type`,而成员列表标签优先读关系表,导致「编辑资料」改身份后标签不刷新。现改为变更后调用 `FamilyRelationshipService.rebuildRelationsForMember(...)`:先按 `from→to` 快照关系质量评分(信任/亲密/沟通,来自关系问卷的答卷人视角),再删旧建新,最后按同方向回填评分(避免清零)。
+  - **锚点规则**:锚点 = 操作者(编辑人)在该家庭的成员记录 —— 管理员给某成员选「配偶」表示「TA 与操作者是配偶」;操作者即目标成员本人时锚点置 null 且不写 `spouse`(关系是相对的,自己不能给自己标配偶)。
+  - **失败策略**:重建失败整事务回滚,不出现「辈分已改、关系未变」的中间态。