面向 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 签名 |
文件:
修改: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"
文件:
修改: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));
}
将现有的 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,避免二次查找。完整代码将在实现时写入。
运行: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"
文件:
检查: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
文件:
修改: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"
ScoredIngredient 在任务2中定义,任务4不涉及后端类型