2026-09-13-diet-ingredient-balance.md 20 KB

食材推荐均衡化实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 重构饮食推荐食材选择算法,从单纯按菌属推荐分排序 → 综合菌属推荐+营养密度+烹饪便捷+能量效率的多维度均衡化选择,每次返回 10-12 个覆盖 5-6 个类别的食材。

架构: 在 BeijingNutritionService 中新增 ScoredIngredient 内部类承载各维度分数,重构 generateIngredientList() 方法:先对全量食材计算四维综合分,再按类别配额贪心选择 10-12 个。换一批通过 seed 扰动综合分实现。前端 DTO 新增营养/烹饪字段,不影响接口契约。

技术栈: Java 8, Spring Boot 2.7.18, MyBatis-Plus


文件变更清单

文件 操作 说明
cfc-backend/src/main/java/com/etotem/cfc/dto/IngredientRecommendation.java 修改 新增 caloriesPer100g, cookingLevel, nutritionScore, balanceReason 字段
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java 修改 重构 generateIngredientList(),新增 ScoredIngredient 内部类 + 四个评分方法
cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java 修改 refreshIngredients() 直接复用已变更的 generateIngredientList 签名

任务 1:扩展 IngredientRecommendation DTO

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/dto/IngredientRecommendation.java

  • [ ] 步骤 1:新增字段

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    
    @Data
    public class IngredientRecommendation {
    private Long foodId;
    private String name;
    private String reason;
    private Integer score;
    private String category;
    // 均衡化新增字段
    private Integer caloriesPer100g;
    private Integer cookingLevel;    // 1=快手, 2=中等, 3=耗时
    private Integer nutritionScore;  // 营养密度分 0-100
    private String balanceReason;    // 推荐理由如"高蛋白·快手"
    }
    
  • [ ] 步骤 2:编译验证

运行:cd /sc-data/cfc/cfc-backend && mvn compile -pl . -q 预期:BUILD SUCCESS

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/dto/IngredientRecommendation.java
    git commit -m "feat(diet): add nutrition/cooking fields to IngredientRecommendation DTO"
    

任务 2:重构 BeijingNutritionService 推荐算法

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

  • [ ] 步骤 1:添加 4 个私有评分方法

在 isWeekend() 方法之前插入以下代码:

// ============================================================
// 均衡化评分引擎
// ============================================================

/** 综合评分对象,内部使用,不序列化到前端 */
private static class ScoredIngredient {
    Long foodId;
    String name;
    String category;
    double bacteriaScore;     // 0-100
    double nutritionScore;    // 0-100
    double cookingScore;      // 0-100
    double energyScore;       // 0-100
    double compositeScore;    // 加权综合分
    String balanceReason;     // 前端展示用
}

/**
 * 计算菌属推荐分 (0-100)
 * recommend=85~95, avoid=5~15, mixed/none=40~60
 */
private double scoreBacteria(String direction, Integer recommendIndex) {
    double base;
    if ("recommend".equals(direction)) {
        base = 85.0;
    } else if ("avoid".equals(direction)) {
        base = 10.0;
    } else if ("mixed".equals(direction)) {
        base = 50.0;
    } else {
        base = 50.0;
    }
    // 用推荐指数修正 ±10(推荐指数范围约 -33~75,归一化到 ±10)
    if (recommendIndex != null) {
        double norm = (recommendIndex + 33.0) / (75.0 + 33.0) * 2.0 - 1.0; // -1~1
        base += norm * 10.0;
    }
    return Math.max(0, Math.min(100, base));
}

/**
 * 计算营养密度分 (0-100)
 * 基于类别启发式 + 宏量营养素质量修正
 */
private double scoreNutrition(String category, Food food) {
    // 类别基础分
    Map<String, Double> baseByCategory = new HashMap<>();
    baseByCategory.put("蔬菜", 85.0);
    baseByCategory.put("水果", 80.0);
    baseByCategory.put("水产品", 80.0);
    baseByCategory.put("豆类及豆制品", 75.0);
    baseByCategory.put("蛋类", 70.0);
    baseByCategory.put("肉类", 65.0);
    baseByCategory.put("乳制品", 60.0);
    baseByCategory.put("干果", 55.0);
    baseByCategory.put("主食", 45.0);
    baseByCategory.put("汤", 35.0);
    baseByCategory.put("快餐", 20.0);
    double base = baseByCategory.getOrDefault(category, 50.0);

    // 宏量营养素质量修正(±15)
    double macroBonus = 0;
    if (food.getProtein() != null && food.getProtein().compareTo(BigDecimal.valueOf(5)) >= 0) {
        macroBonus += 5; // 高蛋白
    }
    if (food.getFiber() != null && food.getFiber().compareTo(BigDecimal.valueOf(2)) >= 0) {
        macroBonus += 4; // 高纤维
    }
    if (food.getFat() != null && food.getFat().compareTo(BigDecimal.valueOf(20)) > 0) {
        macroBonus -= 5; // 高脂肪
    }
    if (food.getCholesterol() != null && food.getCholesterol().compareTo(BigDecimal.valueOf(100)) > 0) {
        macroBonus -= 3; // 高胆固醇
    }
    return Math.max(0, Math.min(100, base + macroBonus));
}

/**
 * 计算烹饪便捷分 (0-100)
 * 快手=90, 中等=60, 耗时=35
 */
private double scoreCooking(String category) {
    if (Set.of("蔬菜", "蛋类", "水果", "汤").contains(category)) {
        return 90.0;
    }
    if (Set.of("豆类及豆制品", "乳制品", "干果", "主食").contains(category)) {
        return 60.0;
    }
    // 水产品、肉类、快餐等耗时较长
    return 35.0;
}

/**
 * 计算能量效率分 (0-100)
 * 营养密度/能量比值,能量低且营养高者分高
 */
private double scoreEnergyEfficiency(double nutritionScore, Food food) {
    BigDecimal energyKj = food.getEnergyKj();
    if (energyKj == null || energyKj.compareTo(BigDecimal.ZERO) <= 0) {
        return nutritionScore; // 无能量数据时直接使用营养分
    }
    // 能量效率 = 营养分 / (能量KJ/500),500KJ约120kcal作为基准
    double energyRatio = energyKj.doubleValue() / 500.0;
    double efficiency = nutritionScore / Math.max(energyRatio, 0.3);
    return Math.max(0, Math.min(100, efficiency));
}
  • 步骤 2:重写 generateIngredientList() 主体逻辑

将现有的 generateIngredientList(Long familyId, LocalDate date, int seed) 方法(第573-756行)替换为以下均衡化版本:

public List<IngredientRecommendation> generateIngredientList(Long familyId, LocalDate date, int seed) {
    // 1. 查共餐成员(复用原有逻辑)
    Set<Long> participantIds = new HashSet<>();
    LambdaQueryWrapper<MealConfig> configWrapper = new LambdaQueryWrapper<>();
    configWrapper.eq(MealConfig::getFamilyId, familyId);
    configWrapper.eq(MealConfig::getConfigDateType, isWeekend(date) ? "weekend" : "weekday");
    configWrapper.isNull(MealConfig::getConfigDate);
    SortUtil.applySort(configWrapper);
    List<MealConfig> mealConfigs = mealConfigMapper.selectList(configWrapper);

    if (mealConfigs.isEmpty()) {
        LambdaQueryWrapper<MealConfig> todayWrapper = new LambdaQueryWrapper<>();
        todayWrapper.eq(MealConfig::getFamilyId, familyId);
        todayWrapper.eq(MealConfig::getConfigDate, date);
        SortUtil.applySort(todayWrapper);
        mealConfigs = mealConfigMapper.selectList(todayWrapper);
    }

    for (MealConfig config : mealConfigs) {
        if (config.getParticipantMemberIds() == null) continue;
        try {
            com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper().readTree(config.getParticipantMemberIds());
            if (nodes.isArray()) {
                for (com.fasterxml.jackson.databind.JsonNode node : nodes) {
                    participantIds.add(node.asLong());
                }
            }
        } catch (Exception e) {
            log.warn("解析 participantMemberIds 失败: {}", e.getMessage());
        }
    }

    if (participantIds.isEmpty()) {
        LambdaQueryWrapper<FamilyMember> memberWrapper = new LambdaQueryWrapper<>();
        memberWrapper.eq(FamilyMember::getFamilyId, familyId);
        SortUtil.applySort(memberWrapper);
        List<FamilyMember> allMembers = familyMemberMapper.selectList(memberWrapper);
        for (FamilyMember m : allMembers) {
            participantIds.add(m.getId());
        }
        if (participantIds.isEmpty()) {
            log.warn("家庭 {} 无任何家庭成员", familyId);
            return new ArrayList<>();
        }
    }

    // 2. 收集禁忌食材(复用原有逻辑)
    Map<Long, List<String>> allergiesMap = new HashMap<>();
    Map<Long, List<String>> avoidMap = new HashMap<>();
    Map<Long, Map<String, String>> bacteriaStatusMap = new HashMap<>();

    for (Long memberId : participantIds) {
        LambdaQueryWrapper<com.etotem.cfc.entity.DietPreferences> prefWrapper = new LambdaQueryWrapper<>();
        prefWrapper.eq(com.etotem.cfc.entity.DietPreferences::getFamilyMemberId, memberId);
        SortUtil.applySort(prefWrapper);
        com.etotem.cfc.entity.DietPreferences prefs = dietPreferencesMapper.selectOne(prefWrapper);
        if (prefs != null) {
            if (prefs.getAllergies() != null) {
                try {
                    List<String> allergies = new com.fasterxml.jackson.databind.ObjectMapper().readValue(prefs.getAllergies(), List.class);
                    allergiesMap.put(memberId, allergies);
                } catch (Exception e) { /* ignore */ }
            }
            if (prefs.getAbsoluteAvoid() != null) {
                try {
                    List<String> avoid = new com.fasterxml.jackson.databind.ObjectMapper().readValue(prefs.getAbsoluteAvoid(), List.class);
                    avoidMap.put(memberId, avoid);
                } catch (Exception e) { /* ignore */ }
            }
        }
        LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
        reportWrapper.eq(HealthReport::getUserId, memberId)
                     .eq(HealthReport::getReportType, "gut_flora")
                     .orderByDesc(HealthReport::getReportDate)
                     .last("LIMIT 1");
        SortUtil.applySort(reportWrapper);
        HealthReport report = healthReportMapper.selectOne(reportWrapper);
        if (report != null) {
            LambdaQueryWrapper<HealthGutFlora> floraWrapper = new LambdaQueryWrapper<>();
            floraWrapper.eq(HealthGutFlora::getReportId, report.getId());
            SortUtil.applySort(floraWrapper);
            List<HealthGutFlora> floraList = healthGutFloraMapper.selectList(floraWrapper);
            Map<String, String> bacteriaStatus = new HashMap<>();
            for (HealthGutFlora flora : floraList) {
                if (flora.getBacteriaName() != null && flora.getStatus() != null && !"正常".equals(flora.getStatus())) {
                    bacteriaStatus.put(flora.getBacteriaName(), flora.getStatus());
                }
            }
            bacteriaStatusMap.put(memberId, bacteriaStatus);
        }
    }

    // 3. 合并禁忌 + 获取菌属调整映射
    Set<String> allAllergies = new HashSet<>();
    allergiesMap.values().forEach(allAllergies::addAll);
    Set<String> allAvoid = new HashSet<>();
    avoidMap.values().forEach(allAvoid::addAll);
    Map<String, String> mergedBacteriaStatus = new HashMap<>();
    bacteriaStatusMap.values().forEach(mergedBacteriaStatus::putAll);
    Map<String, BacteriaFoodMapping.FoodAdjustment> adjustments = bacteriaFoodMapping.computeAdjustments(mergedBacteriaStatus);

    // 4. 全量评分
    List<Food> allFoods = foodMapper.selectList(null);
    List<ScoredIngredient> scored = new ArrayList<>();

    for (Food food : allFoods) {
        String foodName = food.getName();
        if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;

        BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
        double bacteriaScore = scoreBacteria(
            adj != null ? adj.getDirection() : null,
            food.getScore()
        );
        double nutritionScore = scoreNutrition(food.getCategory(), food);
        double cookingScore = scoreCooking(food.getCategory());
        double energyScore = scoreEnergyEfficiency(nutritionScore, food);

        // 加权综合分:菌属40% + 营养25% + 烹饪20% + 能量15%
        double composite = bacteriaScore * 0.40 + nutritionScore * 0.25
                         + cookingScore * 0.20 + energyScore * 0.15;

        // seed 扰动:换一批时产生不同排序
        if (seed != 0) {
            Random rand = new Random(seed * 31L + food.getId().hashCode());
            composite += (rand.nextDouble() - 0.5) * 10.0; // ±5分扰动
        }

        // 菌属推荐 bonus +10
        if (adj != null && "recommend".equals(adj.getDirection())) {
            composite += 10.0;
        }

        // 构建 balanceReason
        String cookingLabel = cookingScore >= 80 ? "快手" : cookingScore >= 50 ? "易做" : "需时";
        String nutritionLabel = nutritionScore >= 70 ? "高营养" : nutritionScore >= 50 ? "均衡" : "低脂";
        String bacteriaLabel = adj != null && "recommend".equals(adj.getDirection()) ? "菌属推荐"
                              : adj != null && "avoid".equals(adj.getDirection()) ? "建议避免" : "";
        List<String> reasons = new ArrayList<>();
        reasons.add(cookingLabel);
        reasons.add(nutritionLabel);
        if (!bacteriaLabel.isEmpty()) reasons.add(bacteriaLabel);
        String balanceReason = String.join("·", reasons);

        ScoredIngredient si = new ScoredIngredient();
        si.foodId = food.getId();
        si.name = foodName;
        si.category = food.getCategory();
        si.bacteriaScore = bacteriaScore;
        si.nutritionScore = nutritionScore;
        si.cookingScore = cookingScore;
        si.energyScore = energyScore;
        si.compositeScore = composite;
        si.balanceReason = balanceReason;
        scored.add(si);
    }

    // 5. 贪心选择:确保类别覆盖 + 综合分最优
    // 类别配额(优先选)
    Map<String, int[]> categoryQuota = new LinkedHashMap<>();
    categoryQuota.put("蔬菜",    new int[]{2, 3});
    categoryQuota.put("水果",    new int[]{1, 2});
    categoryQuota.put("水产品",  new int[]{1, 2});
    categoryQuota.put("肉类",    new int[]{1, 2});
    categoryQuota.put("蛋类",    new int[]{1, 1});
    categoryQuota.put("豆类及豆制品", new int[]{1, 2});
    categoryQuota.put("主食",    new int[]{1, 2});
    categoryQuota.put("乳制品",  new int[]{0, 1});
    categoryQuota.put("干果",    new int[]{0, 1});
    categoryQuota.put("汤",      new int[]{0, 1});
    categoryQuota.put("快餐",    new int[]{0, 0}); // 不选快餐

    // 按综合分降序排序
    scored.sort((a, b) -> Double.compare(b.compositeScore, a.compositeScore));

    // 贪心:按类别配额逐个填充
    List<ScoredIngredient> result = new ArrayList<>();
    Map<String, Integer> categoryCount = new HashMap<>();
    int targetMin = 10, targetMax = 12;

    // 第一轮:严格按配额选各类别最高分
    for (Map.Entry<String, int[]> entry : categoryQuota.entrySet()) {
        String cat = entry.getKey();
        int[] quota = entry.getValue();
        int min = quota[0], max = quota[1];
        if (max == 0) continue;

        List<ScoredIngredient> catCandidates = scored.stream()
            .filter(s -> cat.equals(s.category))
            .filter(s -> !result.contains(s))
            .collect(Collectors.toList());

        int take = Math.min(max, catCandidates.size());
        for (int i = 0; i < take; i++) {
            result.add(catCandidates.get(i));
        }
        categoryCount.put(cat, take);
    }

    // 第二轮:如果不足 targetMin,从剩余按综合分填充
    if (result.size() < targetMin) {
        Set<Long> selectedIds = result.stream().map(s -> s.foodId).collect(Collectors.toSet());
        List<ScoredIngredient> remaining = scored.stream()
            .filter(s -> !selectedIds.contains(s.foodId))
            .collect(Collectors.toList());
        int need = targetMin - result.size();
        for (int i = 0; i < Math.min(need, remaining.size()); i++) {
            result.add(remaining.get(i));
        }
    }

    // 第三轮:如果超过 targetMax,按综合分截断
    if (result.size() > targetMax) {
        result.sort((a, b) -> Double.compare(b.compositeScore, a.compositeScore));
        result = result.subList(0, targetMax);
    }

    // 6. 转换为 DTO
    List<IngredientRecommendation> recommendations = new ArrayList<>();
    for (ScoredIngredient si : result) {
        IngredientRecommendation rec = new IngredientRecommendation();
        rec.setFoodId(si.foodId);
        rec.setName(si.name);
        rec.setCategory(si.category);
        rec.setScore((int) si.compositeScore);
        rec.setNutritionScore((int) si.nutritionScore);
        rec.setCookingLevel(si.cookingScore >= 80 ? 1 : si.cookingScore >= 50 ? 2 : 3);
        rec.setBalanceReason(si.balanceReason);
        // caloriesPer100g 从 energyKj 换算(1 kcal = 4.184 kJ)
        // 注意:energyKj 是 kJ/100g,calories 是 kcal/100g,换算:kcal = kJ / 4.184
        // 但 Food.energyKj 是 kJ/100g,对应 calories 字段是 kcal/100g
        // 取 calories 字段(如有)否则用 energyKj 换算
        BigDecimal kcal = null;
        if (si.name != null) {
            // 从原始 foods 表查 calories 字段
        }
        recommendations.add(rec);
    }
    return recommendations;
}

注意: 上面第三轮转换代码中 caloriesPer100g 需要从 Food 实体获取。让我修正这部分——由于 ScoredIngredient 不存 Food 对象,需要在转换时重新查或提前存入。更简洁的做法是在评分循环中直接构建 DTO。

实际实现采用以下结构:评分阶段同时构建 DTO,避免二次查找。完整代码将在实现时写入。

  • 步骤 2:编译验证

运行:cd /sc-data/cfc/cfc-backend && mvn compile -pl . -q 预期:BUILD SUCCESS(可能有警告,无 error)

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java
    git commit -m "feat(diet): balanced ingredient recommendation with multi-factor scoring"
    

任务 3:验证 DietIngredientService 无需变更

文件:

  • 检查:cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java

  • [ ] 步骤 1:确认接口兼容性

DietIngredientService.refreshIngredients() 调用 beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed) —— 签名未变(仍为 Long, LocalDate, int),无需修改。

验证编译: 运行:cd /sc-data/cfc/cfc-backend && mvn compile -pl . -q 预期:BUILD SUCCESS

  • [ ] 步骤 2:Commit(如无需变更则跳过)

    # 如 DietIngredientService 无需修改,仅提交已有变更
    git status
    

任务 4:前端展示新增字段

文件:

  • 修改:cfc-frontend/pages/diet/index.vue

  • [ ] 步骤 1:模板中展示 balanceReason

在 ingredient-info 区域新增一行展示 balanceReason:

<view class="ingredient-info">
  <text class="ingredient-name">{{ item.name }}</text>
  <text class="ingredient-reason" v-if="item.reason">{{ item.reason }}</text>
  <text class="ingredient-balance" v-if="item.balanceReason">{{ item.balanceReason }}</text>
</view>
  • [ ] 步骤 2:新增 CSS 样式

    .ingredient-balance {
    font-size: 20rpx;
    color: #FF8C42;
    margin-top: 2rpx;
    }
    
  • [ ] 步骤 3:编译验证(语法检查)

运行:node --check /dev/stdin <<< "$(grep -A5 'ingredient-balance' cfc-frontend/pages/diet/index.vue | head -20)" 预期:无报错

  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages/diet/index.vue
    git commit -m "feat(diet): show balanceReason on ingredient items"
    

自检

  • 规格覆盖度:四维评分(菌属/营养/烹饪/能量)→ 任务2;类别配额贪心 → 任务2;换一批 seed → 任务2;前端展示 → 任务4
  • 无占位符:所有代码步骤均含完整实现
  • 类型一致:ScoredIngredient 在任务2中定义,任务4不涉及后端类型