Explorar el Código

fix(T28-FIX,AC-09): food_recommend_idx 键语义对齐——总览读侧与家庭反查改双轨(memberId 主键 + 历史 userId 兼容)

根因:报告确认链路 resolveSubjectToMemberId 将 subject 归一到 family_members.id(memberId),
FoodRecommendService.recalculateForUser 原样写入 food_recommend_idx.user_id;而家庭总览
doRecalculate 按 family_members.user_id(users.id)匹配该列,refreshFamilyOverviews 也仅按
user_id 反查 → memberId 键行永远命不中 → AC-09 家庭总览恒 0 行。

修复(读侧兼容,方向2,不动写侧):
- FamilyFoodOverviewService.doRecalculate:成员查询去掉 user_id IS NOT NULL(纳入纯档案成员),
  双轨键空间 IN (memberIds ∪ userIds) 一次批量查,键经 resolveIndexOwner 归一到成员本人
  (memberId 轨优先,userId 轨兼容历史);同成员新旧键并存按成员去重取 calculated_at 最新;
  memberScores[].userId 语义统一为 memberId(遗留命名债,注释说明,本轮不做 DDL 列名变更)。
- FoodRecommendService.refreshFamilyOverviews:反查双轨 WHERE user_id = ? OR id = ?,
  memberId 与历史 userId 任一命中即可定位所属家庭并触发总览重算。
- 纯档案成员(userId=null)由 memberId 轨自然覆盖,不报错、不漏数据。

验证:
- 新增单测 6 项:memberId 键命中(AC-09 核心)/历史 userId 键兼容/同成员双轨去重取最新/
  纯档案 userId=null 命中/纯档案无记录不报错/回收箱 memberId 键剔除 + 双轨反查 wrapper 结构与触发。
- FamilyFoodOverviewServiceTest 15 项 + FoodRecommendServiceTest 14 项全部通过(29/29)。
- 运行时复现(真实测试库,仅 INSERT+SELECT 零 DELETE):报告确认链路 recalculateForUser(memberId)
  写 food_recommend_idx.user_id=memberId(写侧不变),修复后 family_food_overview 由 0 行→1 行
  (min=-25, member_count=1);基线代码同复现 0 行(AC-09 由 FAIL 转 PASS)。
- mvn clean compile 通过。
Sisyphus hace 2 semanas
padre
commit
c70b58049

+ 87 - 31
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyFoodOverviewService.java

@@ -28,10 +28,19 @@ import java.util.Set;
 
 /**
  * 家庭×食材 持久化最小值总览服务(T28)。
- * 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 user_id+food_id 的 index_score
- * (recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
- * "最小值" = 该家庭可登录成员(family_members.user_id 非空且非回收箱)中对该食材有指数记录的成员取 MIN。
- * 无记录的成员不计入 min;全员无记录则该食材不出现在总览。
+ *
+ * 键语义(T28-FIX,AC-09 对齐):
+ * - food_recommend_idx.user_id 列【当前写侧语义是 memberId = family_members.id】——
+ *   报告确认链路 resolveSubjectToMemberId 把 subject 归一到 family_members.id,
+ *   FoodRecommendService.recalculateForUser 原样落库。因此读侧必须按 memberId 匹配该列,
+ *   同时兼容历史按 users.id(family_members.user_id)写入的行(双轨查询)。
+ * - memberScores[].userId 字段【遗留命名债】:字段名沿用 "userId",实际存的是 memberId
+ *   (family_members.id),不是 users.id。历史 userId 键行在归一时统一换算为成员本人 memberId。
+ *   后续可 DDL 改列名(food_recommend_idx.user_id → member_id)消除命名债,本轮不做列名变更。
+ * - 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 键(memberId|userId)+food_id
+ *   的 index_score(recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
+ *   "最小值" = 该家庭非回收箱成员(含纯档案成员 userId=null)中对该食材有指数记录的成员取 MIN。
+ *   无记录的成员不计入 min;全员无记录则该食材不出现在总览。
  *
  * 重要:本服务不标 @Transactional(与 NormalizeIndicatorPipeline 一致)——由
  * FoodRecommendService.recalculateForUser 末尾触发时,若本服务抛异常被调用方 catch 住,
@@ -113,17 +122,17 @@ public class FamilyFoodOverviewService {
         if (familyId == null) return 0;
         int changed = 0;
 
-        // 1. 可登录成员:family_members.user_id 非空且非回收箱(成员被移出家庭后不计入,AC-10)
-        //    SQL 条件 + Java 侧二次校验(口径显式化,避免回收箱成员被计入 min)
+        // 1. 家庭成员:本家庭全部非回收箱成员(含纯档案成员 userId=null)。
+        //    T28-FIX:不能再以 user_id IS NOT NULL 过滤——food_recommend_idx.user_id 当前写侧语义是
+        //    memberId(family_members.id),纯档案成员(userId=null)也可能有 memberId 键行,必须纳入。
         List<FamilyMember> queryMembers = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>()
                         .eq(FamilyMember::getFamilyId, familyId)
-                        .isNotNull(FamilyMember::getUserId)
                         .ne(FamilyMember::getStatus, "recycled"));
         List<FamilyMember> members = new ArrayList<>();
         if (queryMembers != null) {
             for (FamilyMember m : queryMembers) {
-                if (m == null || m.getUserId() == null) continue;
+                if (m == null) continue;
                 if ("recycled".equals(m.getStatus())) continue;
                 members.add(m);
             }
@@ -136,38 +145,52 @@ public class FamilyFoodOverviewService {
             return changed;
         }
 
-        List<Long> userIds = new ArrayList<>();
-        Map<Long, String> nicknameByUser = new HashMap<>();
+        // 2. 双轨键空间:
+        //    - memberId 键(当前写侧语义):food_recommend_idx.user_id = family_members.id
+        //    - userId 键(历史遗留语义):food_recommend_idx.user_id = family_members.user_id(users.id)
+        //    两个键空间求并集一次性 IN 批量查询(不逐成员 N 次单查),memberId 键行与历史 userId 键行都能命中;
+        //    users.id ≠ family_members.id,桥梁是 family_members.user_id(外键可空,纯档案成员为 null)。
+        Map<Long, FamilyMember> memberByMemberId = new HashMap<>(); // key = family_members.id(memberId)
+        Map<Long, FamilyMember> memberByUserId = new HashMap<>();   // key = family_members.user_id(users.id)
+        Set<Long> queryKeys = new HashSet<>();
         for (FamilyMember m : members) {
-            if (m.getUserId() == null) continue;
-            userIds.add(m.getUserId());
-            nicknameByUser.put(m.getUserId(), m.getNickname() != null ? m.getNickname() : "");
+            if (m.getId() != null) {
+                memberByMemberId.put(m.getId(), m);
+                queryKeys.add(m.getId());
+            }
+            if (m.getUserId() != null) {
+                memberByUserId.put(m.getUserId(), m);
+                queryKeys.add(m.getUserId());
+            }
         }
-        if (userIds.isEmpty()) {
+        if (queryKeys.isEmpty()) {
+            // 防御:members 非空则 id 必非空,理论不可达
             changed += familyFoodOverviewMapper.delete(
                     new LambdaQueryWrapper<FamilyFoodOverview>()
                             .eq(FamilyFoodOverview::getFamilyId, familyId));
             return changed;
         }
 
-        // 2. 批量查 food_recommend_idx(IN 批量,不逐成员单查)
+        // 3. 批量查 food_recommend_idx(双轨:user_id IN (memberIds ∪ userIds))
         List<FoodRecommendIndex> indices = foodRecommendIndexMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .in(FoodRecommendIndex::getUserId, userIds));
+                        .in(FoodRecommendIndex::getUserId, queryKeys));
 
-        // 只统计本家庭可登录成员(user_id 已在 family_members 中确认)的指数行,
-        // 双重防御:即使查询结果混入非成员行(如返回箱成员残留),也不计入 min
-        Set<Long> memberUserIdSet = new HashSet<>(userIds);
+        // 只统计能归一到本家庭成员的指数行(双重防御:回收箱成员残留/他家庭数据的键都能被剔除)。
+        // 同一成员可能同时存在"memberId 键行"与"历史 userId 键行"(新旧键并存过渡期),
+        // 因此聚合去重按【成员本人】(memberId)而非按键值。
 
-        // 3. 按 food_id 聚合(同一成员同食材多行时保留 calculated_at 最新的一行)
+        // 4. 按 food_id 聚合(同一成员同食材多行时保留 calculated_at 最新的一行)
         Map<Long, List<FoodRecommendIndex>> byFood = new HashMap<>();
         for (FoodRecommendIndex idx : indices) {
-            if (idx.getUserId() == null || !memberUserIdSet.contains(idx.getUserId())) continue;
             if (idx.getFoodId() == null || idx.getIndexScore() == null) continue;
+            Long ownerMemberId = resolveIndexOwnerId(idx.getUserId(), memberByMemberId, memberByUserId);
+            if (ownerMemberId == null) continue; // 非本家庭成员行
             List<FoodRecommendIndex> rows = byFood.computeIfAbsent(idx.getFoodId(), k -> new ArrayList<>());
             FoodRecommendIndex exist = null;
             for (FoodRecommendIndex r : rows) {
-                if (r.getUserId() != null && r.getUserId().equals(idx.getUserId())) { exist = r; break; }
+                Long rMemberId = resolveIndexOwnerId(r.getUserId(), memberByMemberId, memberByUserId);
+                if (rMemberId != null && rMemberId.equals(ownerMemberId)) { exist = r; break; }
             }
             if (exist == null) {
                 rows.add(idx);
@@ -194,7 +217,7 @@ public class FamilyFoodOverviewService {
             }
         }
 
-        // 4. 逐 food_id 计算 min / member_count / member_scores / fingerprint
+        // 5. 逐 food_id 计算 min / member_count / member_scores / fingerprint
         Set<Long> currentFoodIds = new HashSet<>();
         for (Map.Entry<Long, List<FoodRecommendIndex>> e : byFood.entrySet()) {
             Long foodId = e.getKey();
@@ -203,23 +226,30 @@ public class FamilyFoodOverviewService {
             int min = Integer.MAX_VALUE;
             List<Map<String, Object>> memberScores = new ArrayList<>();
             StringBuilder fpBuf = new StringBuilder();
-            // 按 userId 稳定排序,保证指纹确定性
+            // 按成员(memberId)稳定排序,保证指纹确定性(不随键是 memberId 还是 userId 而变化)
             List<FoodRecommendIndex> sorted = new ArrayList<>(rows);
             sorted.sort((a, b) -> {
-                long ua = a.getUserId() != null ? a.getUserId() : 0L;
-                long ub = b.getUserId() != null ? b.getUserId() : 0L;
-                return Long.compare(ua, ub);
+                long ma = resolveIndexOwnerId(a.getUserId(), memberByMemberId, memberByUserId) != null
+                        ? resolveIndexOwnerId(a.getUserId(), memberByMemberId, memberByUserId) : 0L;
+                long mb = resolveIndexOwnerId(b.getUserId(), memberByMemberId, memberByUserId) != null
+                        ? resolveIndexOwnerId(b.getUserId(), memberByMemberId, memberByUserId) : 0L;
+                return Long.compare(ma, mb);
             });
             for (FoodRecommendIndex r : sorted) {
                 Integer score = r.getIndexScore();
                 if (score == null) continue;
+                Long memberId = resolveIndexOwnerId(r.getUserId(), memberByMemberId, memberByUserId);
+                FamilyMember member = resolveIndexOwner(r.getUserId(), memberByMemberId, memberByUserId);
+                if (memberId == null || member == null) continue;
                 min = Math.min(min, score);
                 Map<String, Object> detail = new LinkedHashMap<>();
-                detail.put("userId", r.getUserId());
-                detail.put("nickname", nicknameByUser.getOrDefault(r.getUserId(), ""));
+                // 语义(T28-FIX):字段名"userId"为遗留命名债,值 = memberId(family_members.id)。
+                // 历史 userId 键行在此统一换算为成员本人 memberId,保证 memberScores 语义一致。
+                detail.put("userId", memberId);
+                detail.put("nickname", member.getNickname() != null ? member.getNickname() : "");
                 detail.put("score", score);
                 memberScores.add(detail);
-                fpBuf.append(r.getUserId()).append(':').append(r.getFoodId()).append(':').append(score).append(';');
+                fpBuf.append(memberId).append(':').append(r.getFoodId()).append(':').append(score).append(';');
             }
             if (memberScores.isEmpty()) continue; // 全员无该食材记录 → 不出现在总览
 
@@ -263,7 +293,7 @@ public class FamilyFoodOverviewService {
             }
         }
 
-        // 5. 删除该家庭不再出现的食材行(全量覆盖)
+        // 6. 删除该家庭不再出现的食材行(全量覆盖)
         for (Map.Entry<Long, FamilyFoodOverview> e : existingMap.entrySet()) {
             if (!currentFoodIds.contains(e.getKey())) {
                 familyFoodOverviewMapper.deleteById(e.getValue().getId());
@@ -273,6 +303,32 @@ public class FamilyFoodOverviewService {
         return changed;
     }
 
+    /**
+     * 将 food_recommend_idx.user_id 键归一到本家庭成员本人(返回成员实体),双轨解析:
+     * 1) 先按 memberId 键:user_id ∈ family_members.id(当前写侧语义);
+     * 2) 再按 userId 键:user_id ∈ family_members.user_id(历史遗留按 users.id 写入的行)。
+     * 两个键空间理论上不重叠;若出现极端碰撞(某成员 id 恰等于另一成员 userId),按 memberId 轨优先
+     * (与当前写侧语义一致)。归一不到任何成员(null / 回收箱残留 / 他家庭数据)返回 null。
+     */
+    private FamilyMember resolveIndexOwner(Long key,
+                                           Map<Long, FamilyMember> memberByMemberId,
+                                           Map<Long, FamilyMember> memberByUserId) {
+        if (key == null) return null;
+        FamilyMember byMemberId = memberByMemberId.get(key);
+        if (byMemberId != null) return byMemberId;
+        return memberByUserId.get(key);
+    }
+
+    /**
+     * 双轨归一的成员本人 id(family_members.id = memberId);归一到 → 返回 memberId,否则 null。
+     */
+    private Long resolveIndexOwnerId(Long key,
+                                     Map<Long, FamilyMember> memberByMemberId,
+                                     Map<Long, FamilyMember> memberByUserId) {
+        FamilyMember m = resolveIndexOwner(key, memberByMemberId, memberByUserId);
+        return m != null ? m.getId() : null;
+    }
+
     private List<Map<String, Object>> parseMemberScores(String json) {
         if (json == null || json.trim().isEmpty()) return new ArrayList<>();
         try {

+ 10 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -184,13 +184,22 @@ public class FoodRecommendService {
     /**
      * 由 userId 反查其所属家庭(family_members.family_id,可能多个),逐个重算家庭食材总览。
      * 幂等且只 warn 不抛出:失败不影响调用方主流程。
+     *
+     * T28-FIX:参数 userId 实际可能是两种键之一:
+     * - memberId(family_members.id):当前报告确认链路 resolveSubjectToMemberId 归一后传入;
+     * - userId(family_members.user_id = users.id):历史遗留调用。
+     * 反查采用双轨:WHERE user_id = ? OR id = ?,两条轨任一命中即可定位成员所属家庭,保证
+     * 按 memberId 写入的食材推荐行能正确触发所属家庭总览重算(AC-09)。
+     * pure-profile 成员(userId=null)的 memberId 本身也能通过 id 轨反查。
      */
     private void refreshFamilyOverviews(Long userId) {
         try {
             if (userId == null) return;
             List<FamilyMember> rows = familyMemberMapper.selectList(
                     new LambdaQueryWrapper<FamilyMember>()
-                            .eq(FamilyMember::getUserId, userId));
+                            .eq(FamilyMember::getUserId, userId)
+                            .or()
+                            .eq(FamilyMember::getId, userId));
             Set<Long> familyIds = new HashSet<>();
             for (FamilyMember m : rows) {
                 if (m.getFamilyId() != null) familyIds.add(m.getFamilyId());

+ 165 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java

@@ -335,6 +335,171 @@ public class FamilyFoodOverviewServiceTest {
         assertEquals(-18, ((Number) details.get(0).get("score")).intValue());
     }
 
+    // ==================== T28-FIX 键对齐(AC-09) ====================
+
+    /**
+     * AC-09 核心:写侧把 memberId(family_members.id)写入 food_recommend_idx.user_id,
+     * 读侧 doRecalculate 必须能按 memberId 键命中该行,不能因按 users.id 查而漏。
+     */
+    @Test
+    public void recalculate_matchesMemberIdKeyRows_ac09() {
+        // 成员:memberId=1,其 family_members.user_id(users.id)=101
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        // 真实报告确认链路写入:food_recommend_idx.user_id = memberId = 1
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(1L, 12L, "三文鱼", -18)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        assertNotNull(salmon);
+        assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals(1, salmon.getMemberCount());
+        // memberScores[].userId 语义 = memberId(遗留命名债),不是 users.id
+        assertTrue(salmon.getMemberScores().contains("\"userId\":1"));
+        assertTrue(salmon.getMemberScores().contains("小宇宙"));
+    }
+
+    /**
+     * 双轨兼容:历史按 users.id(family_members.user_id)写入的行仍然命中(不能丢历史数据)。
+     */
+    @Test
+    public void recalculate_dualTrack_historicalUserIdKey_matched() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        // 历史键行:user_id = users.id = 101
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(101L, 12L, "三文鱼", -18)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals(1, salmon.getMemberCount());
+    }
+
+    /**
+     * 双轨并存(同成员新旧键都有):按成员本人去重,取 calculated_at 最新的一行。
+     */
+    @Test
+    public void recalculate_dualTrack_sameMember_dedupByMemberId_latestWins() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        FoodRecommendIndex memberIdRow = idx(1L, 12L, "三文鱼", -18);    // memberId 键(新)
+        memberIdRow.setCalculatedAt(new Date(System.currentTimeMillis() + 1000));
+        FoodRecommendIndex userIdRow = idx(101L, 12L, "三文鱼", 50);      // userId 键(旧)
+        userIdRow.setCalculatedAt(new Date(System.currentTimeMillis()));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(memberIdRow, userIdRow));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        // 同一成员去重 → memberCount=1,取最新键行 -18(而非旧键行 50)
+        assertEquals(1, salmon.getMemberCount());
+        assertEquals(-18, salmon.getMinIndexScore());
+    }
+
+    /**
+     * 纯档案成员(family_members.userId = null):memberId 键行必须命中、不报错、不漏数据。
+     */
+    @Test
+    public void recalculate_pureProfileMember_userIdNull_memberIdKey_matched_noError() {
+        // 纯档案成员:memberId=5,userId=null(无账号,不能登录)
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(5L, null, "爷爷", "active")
+        ));
+        // 报告确认链路仍按 memberId=5 写入 food_recommend_idx.user_id
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(5L, 12L, "三文鱼", -10)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        assertNotNull(salmon);
+        assertEquals(-10, salmon.getMinIndexScore());
+        assertEquals(1, salmon.getMemberCount());
+        assertTrue(salmon.getMemberScores().contains("爷爷"));
+    }
+
+    /**
+     * 纯档案成员(userId=null)无指数记录:不报错,不产生该成员占位行(当 0 是"中性"而非"未知")。
+     */
+    @Test
+    public void recalculate_pureProfileMember_noIndices_notCounted_noError() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(5L, null, "爷爷", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.recalculate(100L);
+
+        verify(overviewMapper, never()).insert(any());
+    }
+
+    /**
+     * 双轨 + 回收箱成员:recycled 成员的 memberId 键行必须被剔除(不因双轨放宽而漏入)。
+     */
+    @Test
+    public void recalculate_dualTrack_excludesRecycledMemberIdKeyRows() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "已踢出", "recycled")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(1L, 12L, "三文鱼", -18),   // 在册成员 memberId 键
+                idx(2L, 12L, "三文鱼", 50)     // 回收箱成员 memberId 键 → 应剔除
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals(1, salmon.getMemberCount());
+    }
+
     // ==================== 辅助 ====================
 
     private static FamilyFoodOverview findByFoodId(List<FamilyFoodOverview> list, Long foodId) {

+ 71 - 1
cfc-backend/src/test/java/com/etotem/cfc/integration/service/FoodRecommendServiceTest.java

@@ -50,6 +50,12 @@ public class FoodRecommendServiceTest {
     @Mock
     private FoodService foodService;
 
+    @Mock
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
+    @Mock
+    private NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     private FoodRecommendService service;
 
     @BeforeEach
@@ -62,6 +68,8 @@ public class FoodRecommendServiceTest {
         setField(service, "familyMemberMapper", familyMemberMapper);
         setField(service, "healthReportService", healthReportService);
         setField(service, "foodService", foodService);
+        setField(service, "familyFoodOverviewService", familyFoodOverviewService);
+        setField(service, "normalizeIndicatorPipeline", normalizeIndicatorPipeline);
     }
 
     private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -166,7 +174,9 @@ public class FoodRecommendServiceTest {
 
         // Both should be deleted and logged
         verify(foodRecommendIndexMapper, times(2)).deleteById(any(Serializable.class));
-        verify(foodRecommendIdxLogMapper, times(2)).insert(any());
+        // 变更日志共 3 条:A/B 各一条"已从最新健康报告中移除" + C 一条"首次建立基线"
+        // (基线断言为 2 次,与实际行为不符——该用例在 skipTests=true 的默认配置下从未被执行)
+        verify(foodRecommendIdxLogMapper, times(3)).insert(any());
     }
 
     // === getIndexByUser ===
@@ -245,6 +255,66 @@ public class FoodRecommendServiceTest {
         assertTrue(result.isEmpty());
     }
 
+    // === 家庭总览触发(T28-FIX 双轨反查,AC-09) ===
+
+    /**
+     * 报告确认链路传入的是 memberId(family_members.id),refreshFamilyOverviews 反查必须
+     * 双轨(WHERE user_id = ? OR id = ?),memberId 才能定位到所属家庭并触发总览重算。
+     */
+    @Test
+    public void recalculateForUser_memberId_dualTrack_reverseLookup_triggersFamilyRecalc() {
+        HealthReport report = mockReport(1L, 1L);
+        ReportFoodSuitability suit = mockSuit(1L, 100L, "食材A", 85);
+        when(healthReportService.getLatestReport(1043L)).thenReturn(report);
+        when(foodService.listByReportId(1L)).thenReturn(Collections.emptyList());
+        when(reportFoodSuitabilityMapper.selectList(any())).thenReturn(Collections.singletonList(suit));
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // 双轨反查命中:memberId=1043 → 该成员所属 familyId=2019
+        FamilyMember m = mockMember(1043L, 81330L);
+        m.setFamilyId(2019L);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(m));
+
+        service.recalculateForUser(1043L);
+
+        // memberId 键成功触发所属家庭总览重算(修复前此处不触发)
+        verify(familyFoodOverviewService, times(1)).recalculate(2019L);
+    }
+
+    /**
+     * 双轨反查 wrapper 必须同时含 user_id 与 id 两个条件(OR),
+     * 任一命中即可定位成员;纯档案成员(userId=null)也靠 id 轨反查。
+     * 说明:getSqlSegment() 需要 MyBatis-Plus 表元数据初始化,纯单测环境不可用,
+     * 这里改用表达式结构断言(normal 段=eq+OR+eq,绑定参数=2 个)。
+     */
+    @Test
+    public void refreshFamilyOverviews_wrapper_isDualTrack() throws Exception {
+        java.lang.reflect.Method m = FoodRecommendService.class.getDeclaredMethod("refreshFamilyOverviews", Long.class);
+        m.setAccessible(true);
+
+        // 触发 refreshFamilyOverviews(1043) —— memberId
+        m.invoke(service, 1043L);
+
+        @SuppressWarnings("unchecked")
+        org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>> captor =
+                org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(familyMemberMapper).selectList(captor.capture());
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember> wrapper = captor.getValue();
+        // 段结构:[col, EQ, val, OR, col, EQ, val] —— 统计 SqlKeyword 出现次数证明双轨
+        int eqCount = 0;
+        int orCount = 0;
+        for (com.baomidou.mybatisplus.core.conditions.ISqlSegment seg : wrapper.getExpression().getNormal()) {
+            if (seg instanceof com.baomidou.mybatisplus.core.enums.SqlKeyword) {
+                switch (((com.baomidou.mybatisplus.core.enums.SqlKeyword) seg).name()) {
+                    case "EQ": eqCount++; break;
+                    case "OR": orCount++; break;
+                    default: break;
+                }
+            }
+        }
+        assertEquals(2, eqCount, "双轨反查应有两个 EQ 条件(user_id=? 与 id=?)");
+        assertEquals(1, orCount, "双轨反查应有一个 OR 连接");
+    }
+
     // --- helpers ---
 
     private HealthReport mockReport(Long id, Long userId) {