Przeglądaj źródła

chore: auto bump version and changelog [skip ci]

iwt 1 tydzień temu
rodzic
commit
a9a20180bf

+ 123 - 66
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

@@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.math.BigDecimal;
 import java.util.*;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
@@ -571,21 +572,18 @@ public class BeijingNutritionService {
      * @return 推荐食材列表(Top 15,含推荐理由)
      */
     public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
-        List<IngredientRecommendation> result = new ArrayList<>();
-        
-        // 1. 查 meal_configs → participant_member_ids
+        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);
-        
-        // 2. 收集参与成员
-        Set<Long> participantIds = new HashSet<>();
+
         if (!mealConfigs.isEmpty()) {
             for (MealConfig config : mealConfigs) {
-                // 解析 participantMemberIds JSON
+                if (config.getParticipantMemberIds() == null) continue;
                 try {
                     com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper().readTree(config.getParticipantMemberIds());
                     if (nodes.isArray()) {
@@ -598,9 +596,7 @@ public class BeijingNutritionService {
                 }
             }
         }
-        
-        // 未配置共餐时,降级为查询该家庭全部成员
-        // 当天无默认配置匹配时,优先查当天临时配置(config_date = 当天)
+
         if (participantIds.isEmpty()) {
             LambdaQueryWrapper<MealConfig> todayWrapper = new LambdaQueryWrapper<>();
             todayWrapper.eq(MealConfig::getFamilyId, familyId);
@@ -632,23 +628,21 @@ public class BeijingNutritionService {
             }
             if (participantIds.isEmpty()) {
                 log.warn("家庭 {} 无任何家庭成员", familyId);
-                return result;
+                return new ArrayList<>();
             }
             log.info("家庭 {} 未配置共餐,降级为使用全部 {} 个家庭成员", familyId, allMembers.size());
         }
-        
-        // 3. 查每个 member 的 diet_preferences + health_gut_flora
+
         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 {
@@ -667,8 +661,7 @@ public class BeijingNutritionService {
                     }
                 }
             }
-            
-            // 查最近菌群报告
+
             LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
             reportWrapper.eq(HealthReport::getUserId, memberId)
                          .eq(HealthReport::getReportType, "gut_flora")
@@ -676,13 +669,13 @@ public class BeijingNutritionService {
                          .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())) {
@@ -692,67 +685,125 @@ public class BeijingNutritionService {
                 bacteriaStatusMap.put(memberId, bacteriaStatus);
             }
         }
-        
-        // 4. 合并禁忌食材
+
         Set<String> allAllergies = new HashSet<>();
         allergiesMap.values().forEach(allAllergies::addAll);
         Set<String> allAvoid = new HashSet<>();
         avoidMap.values().forEach(allAvoid::addAll);
-        
-        // 5. 获取所有食材并计算推荐分
-        List<Food> allFoods = foodMapper.selectList(null);
         Map<String, String> mergedBacteriaStatus = new HashMap<>();
         bacteriaStatusMap.values().forEach(mergedBacteriaStatus::putAll);
-        
         Map<String, BacteriaFoodMapping.FoodAdjustment> adjustments = bacteriaFoodMapping.computeAdjustments(mergedBacteriaStatus);
-        
+
+        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;
-            }
-            
+            if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;
+
             BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
-            int score = 50;
-            String reason = "";
-            
-            if (adj != null) {
-                if ("recommend".equals(adj.getDirection())) {
-                    score = 80 + (int)(Math.random() * 15);
-                    reason = adj.getRecommendReasons() != null && !adj.getRecommendReasons().isEmpty() 
-                             ? adj.getRecommendReasons().get(0) : "推荐食材";
-                } else if ("avoid".equals(adj.getDirection())) {
-                    score = 10 + (int)(Math.random() * 20);
-                    reason = "建议避免";
-                } else {
-                    score = 40 + (int)(Math.random() * 20);
-                }
+            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);
+
+            double composite = bacteriaScore * 0.40 + nutritionScore * 0.25
+                             + cookingScore * 0.20 + energyScore * 0.15;
+
+            if (seed != 0) {
+                Random rand = new Random(seed * 31L + food.getId().hashCode());
+                composite += (rand.nextDouble() - 0.5) * 10.0;
+            }
+
+            if (adj != null && "recommend".equals(adj.getDirection())) {
+                composite += 10.0;
+            }
+
+            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);
+        }
+
+        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, CategoryQuota> entry : CATEGORY_QUOTA.entrySet()) {
+            String cat = entry.getKey();
+            int max = entry.getValue().max;
+            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));
             }
-            
-            // 检查是否已在结果中
-            boolean exists = result.stream().anyMatch(r -> r.getFoodId() != null && r.getFoodId().equals(food.getId()));
-            if (!exists) {
-                IngredientRecommendation rec = new IngredientRecommendation();
-                rec.setFoodId(food.getId());
-                rec.setName(foodName);
-                rec.setScore(score);
-                rec.setReason(reason);
-                rec.setCategory(food.getCategory());
-                result.add(rec);
+            categoryCount.put(cat, take);
+        }
+
+        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));
             }
         }
-        
-        // 6. 排序并返回 Top 15
-        result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
-        if (seed != 0 && result.size() > 1) {
-            int offset = Math.abs(seed) % result.size();
-            if (offset > 0) {
-                java.util.Collections.rotate(result, offset);
+
+        if (result.size() > targetMax) {
+            result.sort((a, b) -> Double.compare(b.compositeScore, a.compositeScore));
+            result = result.subList(0, targetMax);
+        }
+
+        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);
+            if (si.name != null) {
+                Food food = allFoods.stream().filter(f -> f.getId().equals(si.foodId)).findFirst().orElse(null);
+                if (food != null) {
+                    if (food.getCalories() != null) {
+                        rec.setCaloriesPer100g(food.getCalories().intValue());
+                    } else if (food.getEnergyKj() != null && food.getEnergyKj().compareTo(BigDecimal.ZERO) > 0) {
+                        rec.setCaloriesPer100g((int) (food.getEnergyKj().doubleValue() / 4.184));
+                    }
+                }
             }
+            recommendations.add(rec);
         }
-        return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
+        return recommendations;
     }
     
     // ============================================================
@@ -848,3 +899,9 @@ public class BeijingNutritionService {
         double ratio = energyKj.doubleValue() / 500.0;
         return Math.max(0, Math.min(100, nutritionScore / Math.max(ratio, 0.3)));
     }
+
+    private boolean isWeekend(java.time.LocalDate date) {
+        return date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY ||
+               date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d9deaa3b9c73959863ee94d32f345d911aa2eea1
+4ead8ab7ecee0904ee7a7b63c1e0f2033010f797

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1391",
+  "version": "1.0.1392",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1391",
+      "version": "1.0.1392",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",