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

feat(diet): T28 食材推荐/查询报告入口收敛 + 家庭总览持久化最小值

需求1 报告入口收敛(从报告进食材推荐/查询时只取当前报告,隐藏成员切换):
- pages/health/gut-flora-foods-detail.vue:报告入口(有 reportId)不渲染
  MemberSelector、onLoad 不调 loadMemberList、loadData 只传 reportId 不传
  memberId、不再请求趋势(照抄 pages/growth/caution-detail.vue 先例)
- pages/diet/food-query.vue:报告入口 member-bar 与 mode-bar 均不渲染,
  不调 loadMembers()/loadFamilyData()(含下拉刷新路径),不请求
  /api/health/foods/by-family;loadFoods 报告入口只传 reportId
- 非报告入口(diet/index.vue、growth-main/index.vue)行为保持不变

需求2 家庭总览改为后端持久化"每项食材的成员最新推荐指数最小值":
- 新增表 family_food_overview(迁移415,唯一键 family_id+food_id):
  min_index_score / member_count / member_scores / source_fingerprint /
  calculated_at;DatabaseInitializer.runMigrations() + schema.sql 同步,幂等
- 新增 FamilyFoodOverview entity / Mapper / FamilyFoodOverviewService:
  批量 IN 查 food_recommend_idx,按 food_id 聚合取 MIN;无记录成员不计入
  (不可当 0),全员无该食材则不出现;全量覆盖式写入,指纹不变跳过写入
- 触发点:FoodRecommendService.recalculateForUser 末尾(含清除指数的提前
  返回分支),由 userId 反查 family_id 逐个重算,try/catch 只 warn,
  绝不拖累报告确认入库主流程(与 NormalizeIndicatorPipeline 一致,不加
  @Transactional 以免污染外层事务 rollback-only)
- 成员移出家庭(kickMember/leaveFamily)后其数据不再计入原家庭总览
- 接口:/api/health/foods/by-family 与 /api/food/recommendation/by-family
  均新增 overview 字段({foodId,foodName,category,minIndexScore,
  memberCount,memberScores,calculatedAt}),原结构不变向后兼容;
  getOverview() 无持久化记录时先 backfill 再返回
- 前端 food-query.vue 直接用 minIndexScore(不再前端算 avg),模板
  item.avgScore → item.score,保留 buildFamilyOverview 作降级路径(按 min),
  标题补充"取家庭最保守值"口径

验证:mvn compile 通过(cfc-backend 独立工程,无父 reactor);新增
FamilyFoodOverviewServiceTest 9 项用例全部通过(min 口径/无记录不计/
回收箱成员排除/全量覆盖/幂等/移除食材/backfill/返回结构);小程序模板与
样式校验通过、反模式与重复接口审计 ERROR 归零。
iwt 2 недель назад
Родитель
Сommit
f90b0078f7

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3293,6 +3293,10 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)
         createAuditLogTable();
 
+        // 迁移415: 创建 family_food_overview 表(家庭×食材 持久化最小值总览;T28)
+        // 家庭总览取"家庭成员该项食材最新推荐指数的最小值"并持久化,由 recalculateForUser 触发重算
+        createFamilyFoodOverviewTable();
+
         // 迁移416: cf_balance_log 补 operator_user_id + 幂等键升级 (member_id,type,ref_type,ref_id)
         //         + CF 提现开关/汇率种子(F-2)
         migrateCfBalanceLogForLedger();
@@ -13981,6 +13985,44 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移415: 创建 family_food_overview 表(家庭×食材 的持久化最小值总览,T28)。
+     * 家庭总览口径:家庭成员对该项食材最新推荐指数的最小值(最保守值),落库持久化。
+     * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
+     */
+    private void createFamilyFoodOverviewTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_food_overview (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "food_id BIGINT NOT NULL COMMENT '食材ID'," +
+                    "food_name VARCHAR(100) COMMENT '食材名称快照'," +
+                    "category VARCHAR(50) COMMENT '分类快照'," +
+                    "min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值'," +
+                    "member_count INT NOT NULL DEFAULT 0 COMMENT '参与计算的成员数(有该食材记录的)'," +
+                    "member_scores JSON COMMENT '成员明细 [{userId,nickname,score}]'," +
+                    "source_fingerprint VARCHAR(64) COMMENT '源数据指纹(成员指数快照哈希),用于幂等与变更检测'," +
+                    "calculated_at DATETIME NOT NULL COMMENT '本次计算时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_family_food (family_id, food_id)," +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材持久化最小值总览(T28)'");
+            log.info("迁移415: family_food_overview 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移415: 创建 family_food_overview 表失败(表可能已存在): {}", e.getMessage());
+        }
+        // 索引兜底:表已存在但索引缺失时补建(幂等)
+        try {
+            if (!indexExists("family_food_overview", "idx_family")) {
+                jdbcTemplate.execute("ALTER TABLE family_food_overview ADD INDEX idx_family (family_id)");
+                log.info("迁移415: 已为 family_food_overview 补建索引 idx_family");
+            }
+        } catch (Exception e) {
+            log.warn("迁移415: family_food_overview 索引检查/补建失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     // ==================== 迁移418:F-4 分佣收口 ====================
 
     /**

+ 14 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java

@@ -6,12 +6,14 @@ import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
 import com.etotem.cfc.service.FamilyMemberService;
 import com.etotem.cfc.service.FoodRecommendService;
+import com.etotem.cfc.service.FamilyFoodOverviewService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -27,6 +29,9 @@ public class FoodRecommendController {
     @Resource
     private FamilyMemberService familyMemberService;
 
+    @Resource
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
     @Operation(summary = "获取用户食材推荐指数")
     @PostMapping("/index")
     public Result<List<FoodRecommendIndex>> getIndex(@RequestBody Map<String, Object> params) {
@@ -52,11 +57,18 @@ public class FoodRecommendController {
 
     @Operation(summary = "按家庭分组获取食材推荐指数")
     @PostMapping("/by-family")
-    public Result<Map<Long, List<FoodRecommendIndex>>> getByFamily(@RequestAttribute(value = "familyId", required = false) Long familyId) {
+    public Result<Map<String, Object>> getByFamily(@RequestAttribute(value = "familyId", required = false) Long familyId) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }
-        Map<Long, List<FoodRecommendIndex>> result = foodRecommendService.getByFamily(familyId);
+        Map<Long, List<FoodRecommendIndex>> byMember = foodRecommendService.getByFamily(familyId);
+        // 原结构保持不变:顶层仍是「成员userId → 推荐指数列表」的映射(JSON key 本就是字符串)
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<FoodRecommendIndex>> entry : byMember.entrySet()) {
+            result.put(String.valueOf(entry.getKey()), entry.getValue());
+        }
+        // T28:家庭总览持久化最小值(与 /api/health/foods/by-family 口径一致)
+        result.put("overview", familyFoodOverviewService.getOverviewMaps(familyId));
         return Result.success(result);
     }
 

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -32,6 +32,7 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.FamilyMemberNutritionProfileService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodRecommendService;
+import com.etotem.cfc.service.FamilyFoodOverviewService;
 import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportDraftService;
@@ -151,6 +152,9 @@ public class HealthReportController {
     @Resource
     private FoodRecommendService foodRecommendService;
 
+    @Resource
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -1962,6 +1966,8 @@ public class HealthReportController {
         result.put("total", totalList);
         result.put("byMember", byMember);
         result.put("memberNames", memberNames);
+        // T28:家庭总览持久化最小值(family_food_overview),无记录时后端先 backfill 再返回
+        result.put("overview", familyFoodOverviewService.getOverviewMaps(familyId));
         return Result.success(result);
     }
 

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyFoodOverview.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 家庭×食材 持久化最小值总览(T28)。
+ * 口径:家庭成员对该项食材"最新推荐指数"的最小值(最保守值),由
+ * {@link com.etotem.cfc.service.FamilyFoodOverviewService#recalculate(Long)} 全量覆盖维护。
+ * memberScores 与项目 JSON 列既有惯例一致(如 diet_recommendations.menuJson)以字符串落库,
+ * 内容为 JSON 数组 [{"userId":1018,"nickname":"小宇宙","score":-18}]
+ */
+@Data
+@TableName("family_food_overview")
+public class FamilyFoodOverview implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 食材ID */
+    private Long foodId;
+
+    /** 食材名称快照 */
+    private String foodName;
+
+    /** 分类快照 */
+    private String category;
+
+    /** 家庭成员该项食材最新推荐指数的最小值 */
+    private Integer minIndexScore;
+
+    /** 参与计算的成员数(有该食材记录的) */
+    private Integer memberCount;
+
+    /** 成员明细 JSON 字符串: [{"userId":1018,"nickname":"小宇宙","score":-18}] */
+    private String memberScores;
+
+    /** 源数据指纹(成员指数快照哈希),用于幂等与变更检测 */
+    private String sourceFingerprint;
+
+    /** 本次计算时间 */
+    private Date calculatedAt;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyFoodOverviewMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.FamilyFoodOverview;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FamilyFoodOverviewMapper extends BaseMapper<FamilyFoodOverview> {
+}

+ 310 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyFoodOverviewService.java

@@ -0,0 +1,310 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyFoodOverview;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.mapper.FamilyFoodOverviewMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 家庭×食材 持久化最小值总览服务(T28)。
+ * 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 user_id+food_id 的 index_score
+ * (recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
+ * "最小值" = 该家庭可登录成员(family_members.user_id 非空且非回收箱)中对该食材有指数记录的成员取 MIN。
+ * 无记录的成员不计入 min;全员无记录则该食材不出现在总览。
+ *
+ * 重要:本服务不标 @Transactional(与 NormalizeIndicatorPipeline 一致)——由
+ * FoodRecommendService.recalculateForUser 末尾触发时,若本服务抛异常被调用方 catch 住,
+ * 不会污染外层"报告确认入库"事务的 rollback-only 标记,主流程不会回滚。
+ */
+@Slf4j
+@Service
+public class FamilyFoodOverviewService {
+
+    @Resource
+    private FamilyFoodOverviewMapper familyFoodOverviewMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private FoodRecommendIndexMapper foodRecommendIndexMapper;
+
+    @Resource
+    private FoodService foodService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 重算并持久化某家庭的食材总览(幂等)。
+     * 按 food_id 全量覆盖:指纹不变的行跳过写入;指纹变化 upsert;不再出现的食材行删除。
+     *
+     * @return 本次实际变更(新增+更新+删除)的行数
+     */
+    public int recalculate(Long familyId) {
+        return doRecalculate(familyId);
+    }
+
+    /**
+     * 读取持久化总览(原始实体列表);该 family 无记录时先 backfill 再返回(AC-11 历史家庭不回空)。
+     */
+    public List<FamilyFoodOverview> getOverview(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyFoodOverview> list = selectByFamily(familyId);
+        if (list == null || list.isEmpty()) {
+            doRecalculate(familyId);
+            list = selectByFamily(familyId);
+        }
+        return list != null ? list : new ArrayList<>();
+    }
+
+    /**
+     * 读取持久化总览并转换为接口返回结构(overview 元素),与后端两处 by-family 接口口径一致。
+     * 元素结构:{foodId, foodName, category, minIndexScore, memberCount,
+     *           memberScores:[{userId,nickname,score}], calculatedAt}
+     */
+    public List<Map<String, Object>> getOverviewMaps(Long familyId) {
+        List<FamilyFoodOverview> list = getOverview(familyId);
+        List<Map<String, Object>> result = new ArrayList<>();
+        SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+        for (FamilyFoodOverview fp : list) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("foodId", fp.getFoodId());
+            item.put("foodName", fp.getFoodName());
+            item.put("category", fp.getCategory());
+            item.put("minIndexScore", fp.getMinIndexScore());
+            item.put("memberCount", fp.getMemberCount());
+            item.put("memberScores", parseMemberScores(fp.getMemberScores()));
+            item.put("calculatedAt", fp.getCalculatedAt() != null ? fmt.format(fp.getCalculatedAt()) : null);
+            result.add(item);
+        }
+        return result;
+    }
+
+    private List<FamilyFoodOverview> selectByFamily(Long familyId) {
+        return familyFoodOverviewMapper.selectList(
+                new LambdaQueryWrapper<FamilyFoodOverview>()
+                        .eq(FamilyFoodOverview::getFamilyId, familyId)
+                        .orderByAsc(FamilyFoodOverview::getMinIndexScore)
+                        .orderByAsc(FamilyFoodOverview::getFoodId));
+    }
+
+    private int doRecalculate(Long familyId) {
+        if (familyId == null) return 0;
+        int changed = 0;
+
+        // 1. 可登录成员:family_members.user_id 非空且非回收箱(成员被移出家庭后不计入,AC-10)
+        //    SQL 条件 + Java 侧二次校验(口径显式化,避免回收箱成员被计入 min)
+        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 ("recycled".equals(m.getStatus())) continue;
+                members.add(m);
+            }
+        }
+        if (members.isEmpty()) {
+            // 无有效成员:清空该家庭总览
+            changed += familyFoodOverviewMapper.delete(
+                    new LambdaQueryWrapper<FamilyFoodOverview>()
+                            .eq(FamilyFoodOverview::getFamilyId, familyId));
+            return changed;
+        }
+
+        List<Long> userIds = new ArrayList<>();
+        Map<Long, String> nicknameByUser = new HashMap<>();
+        for (FamilyMember m : members) {
+            if (m.getUserId() == null) continue;
+            userIds.add(m.getUserId());
+            nicknameByUser.put(m.getUserId(), m.getNickname() != null ? m.getNickname() : "");
+        }
+        if (userIds.isEmpty()) {
+            changed += familyFoodOverviewMapper.delete(
+                    new LambdaQueryWrapper<FamilyFoodOverview>()
+                            .eq(FamilyFoodOverview::getFamilyId, familyId));
+            return changed;
+        }
+
+        // 2. 批量查 food_recommend_idx(IN 批量,不逐成员单查)
+        List<FoodRecommendIndex> indices = foodRecommendIndexMapper.selectList(
+                new LambdaQueryWrapper<FoodRecommendIndex>()
+                        .in(FoodRecommendIndex::getUserId, userIds));
+
+        // 只统计本家庭可登录成员(user_id 已在 family_members 中确认)的指数行,
+        // 双重防御:即使查询结果混入非成员行(如返回箱成员残留),也不计入 min
+        Set<Long> memberUserIdSet = new HashSet<>(userIds);
+
+        // 3. 按 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;
+            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; }
+            }
+            if (exist == null) {
+                rows.add(idx);
+            } else if (idx.getCalculatedAt() != null
+                    && (exist.getCalculatedAt() == null || idx.getCalculatedAt().after(exist.getCalculatedAt()))) {
+                rows.remove(exist);
+                rows.add(idx);
+            }
+        }
+
+        Date now = new Date();
+        List<FamilyFoodOverview> existing = selectByFamily(familyId);
+        Map<Long, FamilyFoodOverview> existingMap = new HashMap<>();
+        for (FamilyFoodOverview fp : existing) {
+            if (fp.getFoodId() != null) existingMap.put(fp.getFoodId(), fp);
+        }
+
+        // 食材名称/分类从 foods 表取快照(缺失时回退 food_recommend_idx.food_name)
+        Map<Long, Food> foodMap = new HashMap<>();
+        if (!byFood.isEmpty()) {
+            List<Food> foods = foodService.listByIds(byFood.keySet());
+            for (Food f : foods) {
+                if (f != null) foodMap.put(f.getId(), f);
+            }
+        }
+
+        // 4. 逐 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();
+            List<FoodRecommendIndex> rows = e.getValue();
+
+            int min = Integer.MAX_VALUE;
+            List<Map<String, Object>> memberScores = new ArrayList<>();
+            StringBuilder fpBuf = new StringBuilder();
+            // 按 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);
+            });
+            for (FoodRecommendIndex r : sorted) {
+                Integer score = r.getIndexScore();
+                if (score == 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(), ""));
+                detail.put("score", score);
+                memberScores.add(detail);
+                fpBuf.append(r.getUserId()).append(':').append(r.getFoodId()).append(':').append(score).append(';');
+            }
+            if (memberScores.isEmpty()) continue; // 全员无该食材记录 → 不出现在总览
+
+            Food food = foodMap.get(foodId);
+            String foodName = food != null && food.getName() != null
+                    ? food.getName()
+                    : (rows.get(0).getFoodName() != null ? rows.get(0).getFoodName() : "");
+            String category = food != null && food.getCategory() != null ? food.getCategory() : "";
+            // 指纹含成员指数快照 + 名称/分类快照:名称变更时也能刷新持久化快照,且无变更时仍跳过写入(幂等)
+            String fingerprint = sha256Hex(familyId + "|" + foodName + "|" + category + "|" + fpBuf);
+
+            FamilyFoodOverview existingRow = existingMap.get(foodId);
+            if (existingRow != null) {
+                currentFoodIds.add(foodId);
+                if (fingerprint.equals(existingRow.getSourceFingerprint())) {
+                    continue; // 指纹不变 → 跳过写入(省 IO),幂等
+                }
+                existingRow.setFoodName(foodName);
+                existingRow.setCategory(category);
+                existingRow.setMinIndexScore(min);
+                existingRow.setMemberCount(memberScores.size());
+                existingRow.setMemberScores(toJson(memberScores));
+                existingRow.setSourceFingerprint(fingerprint);
+                existingRow.setCalculatedAt(now);
+                familyFoodOverviewMapper.updateById(existingRow);
+                changed++;
+            } else {
+                FamilyFoodOverview fp = new FamilyFoodOverview();
+                fp.setFamilyId(familyId);
+                fp.setFoodId(foodId);
+                fp.setFoodName(foodName);
+                fp.setCategory(category);
+                fp.setMinIndexScore(min);
+                fp.setMemberCount(memberScores.size());
+                fp.setMemberScores(toJson(memberScores));
+                fp.setSourceFingerprint(fingerprint);
+                fp.setCalculatedAt(now);
+                familyFoodOverviewMapper.insert(fp);
+                currentFoodIds.add(foodId);
+                changed++;
+            }
+        }
+
+        // 5. 删除该家庭不再出现的食材行(全量覆盖)
+        for (Map.Entry<Long, FamilyFoodOverview> e : existingMap.entrySet()) {
+            if (!currentFoodIds.contains(e.getKey())) {
+                familyFoodOverviewMapper.deleteById(e.getValue().getId());
+                changed++;
+            }
+        }
+        return changed;
+    }
+
+    private List<Map<String, Object>> parseMemberScores(String json) {
+        if (json == null || json.trim().isEmpty()) return new ArrayList<>();
+        try {
+            return objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});
+        } catch (Exception e) {
+            log.warn("解析 family_food_overview.member_scores 失败(按空处理): {}", e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
+    private String toJson(Object value) {
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            log.warn("序列化 member_scores 失败(按空数组处理): {}", e.getMessage());
+            return "[]";
+        }
+    }
+
+    private String sha256Hex(String input) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : bytes) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            // 极端兜底:不可用时退化为原始串(仍保证幂等比对一致)
+            log.warn("SHA-256 计算失败,退化为原文指纹: {}", e.getMessage());
+            return input;
+        }
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -76,6 +76,9 @@ public class FamilyMemberService {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
     /**
      * 校验成员是否属于指定家庭(用于避免跨家庭读写成员数据)
      */
@@ -529,6 +532,7 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
 
         Long oldFamilyId = familyId;
         Long targetUserId = (member != null) ? member.getUserId() : memberId;
+        Long newFamilyId = null;
 
         // 收回该家庭发放给该成员的人口券
         try {
@@ -559,6 +563,7 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                         member.setUpdatedAt(new Date());
                         familyMemberMapper.updateById(member);
                     }
+                    newFamilyId = ownFamilyId;
                     targetUser.setFamilyId(ownFamilyId);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
@@ -583,6 +588,13 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
 
         // 方案B:成员被踢出后,重新核算原家庭会员身份
         membershipService.reconcileFamilyMembership(oldFamilyId);
+
+        // T28:成员被移出家庭后,其食材指数不再计入原家庭总览(AC-10)
+        // 只 warn 不抛出,避免影响踢人主流程
+        refreshFoodOverviewQuietly(oldFamilyId);
+        if (newFamilyId != null && !newFamilyId.equals(oldFamilyId)) {
+            refreshFoodOverviewQuietly(newFamilyId);
+        }
     }
 
     /**
@@ -647,6 +659,25 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         membershipService.reconcileFamilyMembership(oldFamilyId);
 
         logMemberAction(oldFamilyId, member.getId(), "leave", member.getNickname(), null, userId);
+
+        // T28:成员退出后,其食材指数不再计入原家庭总览(AC-10)
+        // 只 warn 不抛出,避免影响退出主流程
+        refreshFoodOverviewQuietly(oldFamilyId);
+        if (ownFamilyId != null && !ownFamilyId.equals(oldFamilyId)) {
+            refreshFoodOverviewQuietly(ownFamilyId);
+        }
+    }
+
+    /**
+     * T28 辅助:安静地重算某家庭食材总览(失败只 warn,不影响调用方主流程)。
+     */
+    private void refreshFoodOverviewQuietly(Long familyId) {
+        try {
+            if (familyId == null) return;
+            familyFoodOverviewService.recalculate(familyId);
+        } catch (Exception e) {
+            log.warn("家庭食材总览重算失败 familyId={}: {}", familyId, e.getMessage());
+        }
     }
 
     /**

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -44,6 +44,9 @@ public class FoodRecommendService {
     @Resource
     private FoodService foodService;
 
+    @Resource
+    private FamilyFoodOverviewService familyFoodOverviewService;
+
     @Transactional
     public void recalculateForUser(Long userId) {
         HealthReport report = healthReportService.getLatestReport(userId);
@@ -51,6 +54,7 @@ public class FoodRecommendService {
             foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
                     .eq(FoodRecommendIndex::getUserId, userId));
             log.info("用户{}无最新健康报告,已清除推荐指数", userId);
+            refreshFamilyOverviews(userId);
             return;
         }
 
@@ -65,6 +69,7 @@ public class FoodRecommendService {
                 foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
                         .eq(FoodRecommendIndex::getUserId, userId));
                 log.info("报告{}无食材推荐数据,已清除用户{}的推荐指数", report.getId(), userId);
+                refreshFamilyOverviews(userId);
                 return;
             }
             // 回退:将 report_food_suitability 映射为 Food 列表后继续处理
@@ -170,6 +175,40 @@ public class FoodRecommendService {
         } catch (Exception e) {
             log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
         }
+
+        // 家庭食材总览重算(T28):最新指数已全量覆盖写入,刷新所属家庭的持久化最小值总览。
+        // try/catch + 只 warn,绝不因总览重算失败让报告确认入库主流程回滚(与归一化管道处理方式一致)
+        refreshFamilyOverviews(userId);
+    }
+
+    /**
+     * 由 userId 反查其所属家庭(family_members.family_id,可能多个),逐个重算家庭食材总览。
+     * 幂等且只 warn 不抛出:失败不影响调用方主流程。
+     */
+    private void refreshFamilyOverviews(Long userId) {
+        try {
+            if (userId == null) return;
+            List<FamilyMember> rows = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId));
+            Set<Long> familyIds = new HashSet<>();
+            for (FamilyMember m : rows) {
+                if (m.getFamilyId() != null) familyIds.add(m.getFamilyId());
+            }
+            for (Long fid : familyIds) {
+                try {
+                    familyFoodOverviewService.recalculate(fid);
+                } catch (Exception e) {
+                    // 单个家庭失败不影响其他家庭
+                    log.warn("家庭食材总览重算失败 userId={} familyId={}: {}", userId, fid, e.getMessage());
+                }
+            }
+            if (!familyIds.isEmpty()) {
+                log.info("用户{}所属{}个家庭的食材总览已重算", userId, familyIds.size());
+            }
+        } catch (Exception e) {
+            log.warn("家庭食材总览重算失败 userId={}: {}", userId, e.getMessage());
+        }
     }
 
     public List<FoodRecommendIndex> getIndexByUser(Long userId) {

+ 22 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4167,6 +4167,28 @@ CREATE TABLE IF NOT EXISTS report_food_suitability (
     INDEX idx_food (food_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告食材适宜度';
 
+-- ============================================================
+-- 家庭×食材持久化最小值总览表(迁移415 / T28)
+--   家庭总览口径:家庭成员对该项食材最新推荐指数的最小值(最保守值),落库持久化。
+--   member_scores 存 JSON 数组: [{"userId":1018,"nickname":"小宇宙","score":-18}]
+-- ============================================================
+CREATE TABLE IF NOT EXISTS family_food_overview (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    food_id BIGINT NOT NULL COMMENT '食材ID',
+    food_name VARCHAR(100) COMMENT '食材名称快照',
+    category VARCHAR(50) COMMENT '分类快照',
+    min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值',
+    member_count INT NOT NULL DEFAULT 0 COMMENT '参与计算的成员数(有该食材记录的)',
+    member_scores JSON COMMENT '成员明细 [{userId,nickname,score}]',
+    source_fingerprint VARCHAR(64) COMMENT '源数据指纹(成员指数快照哈希),用于幂等与变更检测',
+    calculated_at DATETIME NOT NULL COMMENT '本次计算时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_food (family_id, food_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材持久化最小值总览(T28)';
+
 -- ============================================================
 -- 家庭共餐选中食材表(持久化共餐场景食材选择)
 -- ============================================================

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

@@ -0,0 +1,346 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyFoodOverview;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.mapper.FamilyFoodOverviewMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * FamilyFoodOverviewService 纯单元测试(T28)
+ * 校验家庭总览口径:家庭成员该项食材最新推荐指数的最小值;无记录成员不计入。
+ * 使用 Mockito(无 Spring 上下文,无 MySQL 连接)。
+ */
+public class FamilyFoodOverviewServiceTest {
+
+    private FamilyFoodOverviewService service;
+
+    @Mock
+    private FamilyFoodOverviewMapper overviewMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private FoodRecommendIndexMapper idxMapper;
+
+    @Mock
+    private FoodService foodService;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new FamilyFoodOverviewService();
+        setField(service, "familyFoodOverviewMapper", overviewMapper);
+        setField(service, "familyMemberMapper", familyMemberMapper);
+        setField(service, "foodRecommendIndexMapper", idxMapper);
+        setField(service, "foodService", foodService);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private FamilyMember member(Long id, Long userId, String nickname, String status) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(100L);
+        m.setUserId(userId);
+        m.setNickname(nickname);
+        m.setStatus(status);
+        return m;
+    }
+
+    private FoodRecommendIndex idx(Long userId, Long foodId, String foodName, Integer score) {
+        FoodRecommendIndex i = new FoodRecommendIndex();
+        i.setId(userId * 100 + foodId);
+        i.setUserId(userId);
+        i.setFoodId(foodId);
+        i.setFoodName(foodName);
+        i.setIndexScore(score);
+        i.setCalculatedAt(new Date());
+        return i;
+    }
+
+    private Food food(Long id, String name, String category) {
+        Food f = new Food();
+        f.setId(id);
+        f.setName(name);
+        f.setCategory(category);
+        return f;
+    }
+
+    // ==================== 最小值口径 ====================
+
+    @Test
+    public void recalculate_takesMinAcrossMembers() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", -18),
+                idx(102L, 12L, "三文鱼", 22),
+                idx(101L, 13L, "菠菜", 30),
+                idx(102L, 13L, "菠菜", 12)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Arrays.asList(
+                food(12L, "三文鱼", "水产"),
+                food(13L, "菠菜", "蔬菜")
+        ));
+
+        service.recalculate(100L);
+
+        // 抓取插入的两行,校验 min_index_score / member_count
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(2)).insert(captor.capture());
+        List<FamilyFoodOverview> inserted = captor.getAllValues();
+
+        FamilyFoodOverview salmon = findByFoodId(inserted, 12L);
+        assertNotNull(salmon);
+        assertEquals(-18, salmon.getMinIndexScore());   // min(-18, 22)
+        assertEquals(2, salmon.getMemberCount());
+        assertEquals("三文鱼", salmon.getFoodName());
+        assertEquals("水产", salmon.getCategory());
+        assertTrue(salmon.getMemberScores().contains("小宇宙"));
+        assertTrue(salmon.getMemberScores().contains("\"score\":-18"));
+
+        FamilyFoodOverview spinach = findByFoodId(inserted, 13L);
+        assertNotNull(spinach);
+        assertEquals(12, spinach.getMinIndexScore());   // min(30, 12)
+        assertEquals(2, spinach.getMemberCount());
+    }
+
+    @Test
+    public void recalculate_memberWithoutRecordNotCountedInMin() {
+        // 妈妈没有"三文鱼"记录 → 不计入 min,也不能当 0
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        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());
+    }
+
+    @Test
+    public void recalculate_foodWithNoRecordsAnywhere_doesNotAppear() {
+        // 成员都没有任何食材记录 → 总览为空,不插入任何行
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.recalculate(100L);
+
+        verify(overviewMapper, never()).insert(any());
+    }
+
+    @Test
+    public void recalculate_excludesRecycledMembers() {
+        // 回收箱成员(status=recycled)不计入 —— SQL + Java 双重过滤,本测试只断言 Java 侧兜底
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "已踢出", "recycled")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", -18),
+                idx(102L, 12L, "三文鱼", 50)
+        ));
+        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);
+        // 仅计入小宇宙 -18,已踢出 50 被过滤 → min = -18, count = 1
+        assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals(1, salmon.getMemberCount());
+    }
+
+    @Test
+    public void recalculate_noValidMembers_clearsExistingRows() {
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.recalculate(100L);
+
+        verify(overviewMapper).delete(any());
+        verify(overviewMapper, never()).insert(any());
+    }
+
+    // ==================== 幂等 / 全量覆盖 ====================
+
+    @Test
+    public void recalculate_sameFingerprint_skipsWrite() {
+        FamilyFoodOverview existing = new FamilyFoodOverview();
+        existing.setId(1L);
+        existing.setFamilyId(100L);
+        existing.setFoodId(12L);
+        existing.setFoodName("三文鱼");
+        existing.setCategory("水产");
+        existing.setMinIndexScore(-18);
+        existing.setMemberCount(1);
+        existing.setMemberScores("[]");
+        existing.setSourceFingerprint("stale-fingerprint");
+
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(101L, 12L, "三文鱼", -18)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(existing));
+        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)).updateById(captor.capture());
+        String computed = captor.getValue().getSourceFingerprint();
+        assertNotNull(computed);
+
+        // 第二次:库内指纹已等于本次计算值 → 幂等跳过写入
+        existing.setSourceFingerprint(computed);
+        reset(overviewMapper);
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(existing));
+        service.recalculate(100L);
+        verify(overviewMapper, never()).updateById(any());
+        verify(overviewMapper, never()).insert(any());
+        verify(overviewMapper, never()).deleteById(any());
+    }
+
+    @Test
+    public void recalculate_removesFoodsNoLongerPresent() {
+        FamilyFoodOverview stale = new FamilyFoodOverview();
+        stale.setId(9L);
+        stale.setFamilyId(100L);
+        stale.setFoodId(99L);
+        stale.setFoodName("已删除食材");
+        stale.setCategory("蔬菜");
+        stale.setMinIndexScore(5);
+        stale.setMemberCount(1);
+        stale.setSourceFingerprint("old");
+
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(101L, 12L, "三文鱼", -18)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(stale));
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        verify(overviewMapper).deleteById(9L);
+    }
+
+    // ==================== 读取 / backfill ====================
+
+    @Test
+    public void getOverview_noPersistedRecords_backfillsFirst() {
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
+                member(1L, 101L, "小宇宙", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
+                idx(101L, 12L, "三文鱼", -18)
+        ));
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        List<FamilyFoodOverview> result = service.getOverview(100L);
+
+        // 无记录 → 先 backfill 再返回:getOverview 首次读空 + recalculate 内读现有 + backfill 后再读
+        verify(overviewMapper).insert(any());
+        verify(overviewMapper, times(3)).selectList(any());
+        assertNotNull(result);
+    }
+
+    @Test
+    public void getOverviewMaps_exposesSpecShape() {
+        FamilyFoodOverview row = new FamilyFoodOverview();
+        row.setFamilyId(100L);
+        row.setFoodId(12L);
+        row.setFoodName("三文鱼");
+        row.setCategory("水产");
+        row.setMinIndexScore(-18);
+        row.setMemberCount(2);
+        row.setMemberScores("[{\"userId\":101,\"nickname\":\"小宇宙\",\"score\":-18},{\"userId\":102,\"nickname\":\"妈妈\",\"score\":22}]");
+        row.setCalculatedAt(new Date());
+
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(row));
+
+        List<Map<String, Object>> maps = service.getOverviewMaps(100L);
+
+        assertEquals(1, maps.size());
+        Map<String, Object> m = maps.get(0);
+        assertEquals(12L, ((Number) m.get("foodId")).longValue());
+        assertEquals("三文鱼", m.get("foodName"));
+        assertEquals("水产", m.get("category"));
+        assertEquals(-18, ((Number) m.get("minIndexScore")).intValue());
+        assertEquals(2, ((Number) m.get("memberCount")).intValue());
+        assertNotNull(m.get("calculatedAt"));
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> details = (List<Map<String, Object>>) m.get("memberScores");
+        assertEquals(2, details.size());
+        assertEquals("小宇宙", details.get(0).get("nickname"));
+        assertEquals(-18, ((Number) details.get(0).get("score")).intValue());
+    }
+
+    // ==================== 辅助 ====================
+
+    private static FamilyFoodOverview findByFoodId(List<FamilyFoodOverview> list, Long foodId) {
+        for (FamilyFoodOverview fp : list) {
+            if (foodId.equals(fp.getFoodId())) return fp;
+        }
+        return null;
+    }
+}

+ 56 - 25
cfc-frontend/pages/diet/food-query.vue

@@ -1,9 +1,10 @@
 <template>
   <view class="food-query-page" :style="{ '--font-scale': fontScale }">
     <!-- 成员切换条(统一组件 MemberSelector:家庭总览 / 个人 / 各成员,同一用户只出现一次) -->
+    <!-- T28:报告入口(有 reportId)不渲染成员切换,只取当前报告 -->
     <MemberSelector
       variant="minimal"
-      v-if="memberList.length > 1"
+      v-if="!reportId && memberList.length > 1"
       :members="memberList"
       :selected-member-id="currentMemberId"
       :show-overview="true"
@@ -11,8 +12,8 @@
       @overview="switchView('family')"
       @select="switchMember" />
 
-    <!-- 模式切换(仅家庭模式展示) -->
-    <view class="mode-bar" v-if="familyMode">
+    <!-- 模式切换(仅家庭模式展示;报告入口不显示) -->
+    <view class="mode-bar" v-if="!reportId && familyMode">
       <view class="mode-btn" :class="{'active': sortType === 'desc'}" @tap="setSort('desc')">
         <text>推荐值 ↓</text>
       </view>
@@ -67,16 +68,16 @@
 
       <!-- 列表区(仅此区域滚动) -->
       <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
-      <!-- 家庭总览 -->
+      <!-- 家庭总览(读后端持久化 overview:家庭成员该项食材最新推荐指数的最小值) -->
       <view class="family-overview" v-if="currentView === 'family' && familyOverview && familyOverview.length > 0">
         <view class="overview-title">
-          <text>🏠 家庭推荐概况(共 {{ familyMembers.length }} 人)</text>
+          <text>🏠 家庭推荐概况(共 {{ familyMembers.length }} 人 · 取家庭最保守值)</text>
         </view>
         <view class="overview-item" v-for="(item, idx) in familyOverview" :key="idx">
           <view class="overview-header">
             <text class="food-name">{{ item.name }}</text>
             <view class="score-badge" :class="item.color">
-              <text>{{ item.avgScore }}</text>
+              <text>{{ item.score }}</text>
             </view>
           </view>
           <view class="overview-detail">
@@ -217,11 +218,12 @@ export default {
     if (this.reportId) {
       this.filterType = 'unsuitable'
       this.sortType = 'asc'
+    } else {
+      this.loadMembers()
+      this.loadFamilyData()
     }
     
-    this.loadMembers()
     this.loadFoods()
-    this.loadFamilyData()
   },
   methods: {
     getFoodKey: function(item, idx) {
@@ -233,7 +235,7 @@ export default {
     loadMembers: function() {
       var self = this
       var token = uni.getStorageSync('token')
-      if (!token) return
+      if (!token || self.reportId) return
       
       uni.request({
         url: config.API_BASE_URL + '/api/food/recommendation/members',
@@ -259,8 +261,12 @@ export default {
       self.loading = true
       var url = config.API_BASE_URL + '/api/health/foods'
       var data = {}
-      if (self.reportId) data.reportId = self.reportId
-      if (self.currentMemberId) data.memberId = self.currentMemberId
+      // T28:报告入口只传 reportId(不传 memberId);非报告入口传 memberId
+      if (self.reportId) {
+        data.reportId = self.reportId
+      } else if (self.currentMemberId) {
+        data.memberId = self.currentMemberId
+      }
       
       uni.request({
         url: url,
@@ -286,7 +292,7 @@ export default {
     loadFamilyData: function() {
       var self = this
       var token = uni.getStorageSync('token')
-      if (!token) return
+      if (!token || self.reportId) return
       
       var familyId = uni.getStorageSync('familyId')
       if (!familyId) return
@@ -301,7 +307,29 @@ export default {
             self.familyMembers = Object.keys(res.data.data.byMember || {}).map(function(key) {
               return { userId: key, nickname: res.data.data.memberNames[key] || '成员' }
             })
-            self.buildFamilyOverview(res.data.data.totalList, res.data.data.byMember, res.data.data.memberNames)
+            // T28:优先使用后端持久化总览(overview,minIndexScore = 家庭成员该项食材最新推荐指数的最小值)
+            if (res.data.data.overview && res.data.data.overview.length > 0) {
+              self.familyOverview = res.data.data.overview.map(function(o) {
+                var min = o.minIndexScore
+                return {
+                  id: o.foodId,
+                  name: o.foodName,
+                  category: o.category,
+                  score: min,
+                  scoreClass: self.getScoreClass(min),
+                  color: self.getScoreClass(min),
+                  statusText: self.getStatusText(min),
+                  status: min >= 0 ? 'suitable' : 'unsuitable',
+                  memberCount: o.memberCount || 0,
+                  memberScores: (o.memberScores || []).map(function(s) {
+                    return { name: s.nickname || '', score: s.score }
+                  })
+                }
+              })
+            } else {
+              // 后端未返回 overview 时的降级路径:前端按 min 计算(比 avg 更安全)
+              self.buildFamilyOverview(res.data.data.total || res.data.data.totalList, res.data.data.byMember, res.data.data.memberNames)
+            }
           }
         }
       })
@@ -309,11 +337,10 @@ export default {
     buildFamilyOverview: function(totalList, byMember, memberNames) {
       var self = this
       var overview = []
-      for (var i = 0; i < totalList.length; i++) {
-        var food = totalList[i]
+      var list = totalList || []
+      for (var i = 0; i < list.length; i++) {
+        var food = list[i]
         var memberScores = []
-        var totalScore = 0
-        var count = 0
         
         for (var j = 0; j < self.familyMembers.length; j++) {
           var member = self.familyMembers[j]
@@ -323,30 +350,34 @@ export default {
               if (memberFoods[k].foodId === food.id) {
                 var score = memberFoods[k].indexScore || 0
                 memberScores.push({ name: member.nickname, score: score })
-                totalScore += score
-                count++
                 break
               }
             }
           }
         }
         
+        // T28 降级路径:取家庭成员该项食材的最小值(最保守值),比 avg 更安全
         if (memberScores.length > 0) {
-          var avgScore = Math.round(totalScore / memberScores.length)
+          var minScore = memberScores[0].score
+          for (var mi = 1; mi < memberScores.length; mi++) {
+            if (memberScores[mi].score < minScore) minScore = memberScores[mi].score
+          }
           overview.push({
             id: food.id,
             name: food.name,
             category: food.category,
-            score: avgScore,
-            scoreClass: self.getScoreClass(avgScore),
-            statusText: self.getStatusText(avgScore),
-            status: avgScore >= 0 ? 'suitable' : 'unsuitable',
+            score: minScore,
+            scoreClass: self.getScoreClass(minScore),
+            color: self.getScoreClass(minScore),
+            statusText: self.getStatusText(minScore),
+            status: minScore >= 0 ? 'suitable' : 'unsuitable',
+            memberCount: memberScores.length,
             memberScores: memberScores
           })
         }
       }
       
-      // 按平均推荐值排序(从高到低)
+      // 按最小推荐值排序(从高到低)
       overview.sort(function(a, b) {
         return b.score - a.score
       })

+ 13 - 4
cfc-frontend/pages/health/gut-flora-foods-detail.vue

@@ -2,9 +2,10 @@
   <view class="page" :style="{ '--font-scale': fontScale }">
 
     <!-- 成员切换条(统一组件 MemberSelector:同一用户只出现一次,主键为 canonical memberId) -->
+    <!-- T28:报告入口(有 reportId)不渲染成员切换,只取当前报告 -->
     <MemberSelector
       variant="minimal"
-      v-if="memberList.length > 0"
+      v-if="!reportId && memberList.length > 0"
       :members="memberList"
       :selected-member-id="currentMemberId"
       :show-overview="true"
@@ -206,7 +207,10 @@ export default {
     this.reportId = options.reportId ? parseInt(options.reportId) : null
     this.memberId = options.memberId ? parseInt(options.memberId) : null
     this.currentMemberId = this.memberId
-    this.loadMemberList()
+    // T28:报告入口只取当前报告,不加载成员切换列表(照抄 caution-detail 模式)
+    if (!this.reportId) {
+      this.loadMemberList()
+    }
     this.loadData()
   },
 methods: {
@@ -265,7 +269,9 @@ methods: {
     loadData: function() {
       var self = this
       self.loading = true
-      getHealthFoods(self.reportId, self.currentMemberId > 0 ? self.currentMemberId : null).then(function(res) {
+      // T28:报告入口只传 reportId(不传 memberId);非报告入口才传 currentMemberId
+      var mid = (!self.reportId && self.currentMemberId > 0) ? self.currentMemberId : null
+      getHealthFoods(self.reportId, mid).then(function(res) {
         self.loading = false
         if (res.code === 200 && res.data) {
           self.foods = (Array.isArray(res.data) ? res.data : []).map(function(item) {
@@ -286,7 +292,10 @@ methods: {
               trendPoints: null
             })
           })
-          self.loadTrends()
+          // T28:报告入口不查趋势(报告快照无历史,照抄 caution-detail)
+          if (!self.reportId) {
+            self.loadTrends()
+          }
         } else {
           self.foods = []
         }