Bläddra i källkod

feat(backend): 家庭共餐食材推荐持久化到数据库

- 新增 diet_family_selected_ingredients 表(迁移354)
- 新增 DietFamilySelectedIngredient 实体和 Mapper
- DietIngredientService 改用数据库持久化替代内存 Map
- suggestIngredients 优先从 DB 加载已有选择
- refreshIngredients 清空并重新持久化推荐列表
- add/removeIngredient 直接操作 DB
- confirmIngredients 保留已选食材(不再清空)
E2E Test Bot 3 dagar sedan
förälder
incheckning
fac9492416

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

@@ -10945,6 +10945,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         addAbTestColumnsToRecommendationLog();
         createTaskReminderConfigTable();
         addMissingColumnsToRecommendationLog();
+        createDietFamilySelectedIngredientsTable();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11852,6 +11853,33 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移354: 家庭共餐选中食材表(持久化共餐场景食材选择)
+    private void createDietFamilySelectedIngredientsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "food_id BIGINT NOT NULL COMMENT '食材ID'," +
+                    "food_name VARCHAR(100) COMMENT '食材名称'," +
+                    "score INT COMMENT '推荐分'," +
+                    "reason VARCHAR(255) COMMENT '推荐理由'," +
+                    "category VARCHAR(50) COMMENT '分类'," +
+                    "calories_per_100g INT COMMENT '每100克卡路里'," +
+                    "cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)'," +
+                    "nutrition_score INT COMMENT '营养密度分(0-100)'," +
+                    "balance_reason VARCHAR(255) COMMENT '均衡化推荐理由'," +
+                    "sort_order INT DEFAULT 0 COMMENT '排序'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "UNIQUE KEY uk_family_food (family_id, food_id)," +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材'");
+            log.info("迁移354: 已创建 diet_family_selected_ingredients 表");
+        } catch (Exception e) {
+            log.warn("迁移354: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移355: product_recommendation_log 补齐缺失列
     // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列),
     // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at,
@@ -12020,4 +12048,31 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         return sb.append(']').toString();
     }
+
+    // 迁移354: 家庭共餐选中食材表(持久化共餐场景食材选择)
+    private void createDietFamilySelectedIngredientsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "food_id BIGINT NOT NULL COMMENT '食材ID'," +
+                    "food_name VARCHAR(100) COMMENT '食材名称'," +
+                    "score INT COMMENT '推荐分'," +
+                    "reason VARCHAR(255) COMMENT '推荐理由'," +
+                    "category VARCHAR(50) COMMENT '分类'," +
+                    "calories_per_100g INT COMMENT '每100克卡路里'," +
+                    "cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)'," +
+                    "nutrition_score INT COMMENT '营养密度分(0-100)'," +
+                    "balance_reason VARCHAR(255) COMMENT '均衡化推荐理由'," +
+                    "sort_order INT DEFAULT 0 COMMENT '排序'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "UNIQUE KEY uk_family_food (family_id, food_id)," +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭共餐选中食材'");
+            log.info("迁移354: 已创建 diet_family_selected_ingredients 表");
+        } catch (Exception e) {
+            log.warn("迁移354: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage());
+        }
+    }
 }

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietFamilySelectedIngredient.java

@@ -0,0 +1,42 @@
+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;
+
+@Data
+@TableName("diet_family_selected_ingredients")
+public class DietFamilySelectedIngredient implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 家庭ID */
+    private Long familyId;
+    /** 食材ID */
+    private Long foodId;
+    /** 食材名称 */
+    private String foodName;
+    /** 推荐分(0-100) */
+    private Integer score;
+    /** 推荐理由 */
+    private String reason;
+    /** 分类 */
+    private String category;
+    /** 每100克卡路里 */
+    private Integer caloriesPer100g;
+    /** 烹饪难度(1=快手,2=中等,3=耗时) */
+    private Integer cookingLevel;
+    /** 营养密度分(0-100) */
+    private Integer nutritionScore;
+    /** 均衡化推荐理由 */
+    private String balanceReason;
+    /** 排序序号 */
+    private Integer sortOrder;
+    /** 创建时间 */
+    private Date createdAt;
+    /** 更新时间 */
+    private Date updatedAt;
+}

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

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

+ 130 - 40
cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java

@@ -1,7 +1,10 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.IngredientRecommendation;
+import com.etotem.cfc.entity.DietFamilySelectedIngredient;
 import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.DietFamilySelectedIngredientMapper;
 import com.etotem.cfc.mapper.FoodMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -9,6 +12,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.time.LocalDate;
 import java.util.*;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -20,18 +24,39 @@ public class DietIngredientService {
     @Resource
     private FoodMapper foodMapper;
 
-    // 会话内食材选择(按 familyId 隔离)
-    private final Map<Long, List<IngredientRecommendation>> selectedIngredients = new HashMap<>();
+    @Resource
+    private DietFamilySelectedIngredientMapper selectedIngredientMapper;
+
+    // 会话内 refresh 种子(重启后重置,不影响持久化的食材选择)
     private final Map<Long, Integer> refreshSeed = new HashMap<>();
 
+    // ============================================================
+    // 共餐食材推荐(持久化)
+    // ============================================================
+
     public Map<String, Object> suggestIngredients(Long familyId) {
+        // 优先从数据库加载已有选择
+        List<DietFamilySelectedIngredient> dbItems = selectedIngredientMapper.selectList(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .orderByAsc(DietFamilySelectedIngredient::getSortOrder));
+
+        if (!dbItems.isEmpty()) {
+            List<IngredientRecommendation> existing = dbItemsToRecommendations(dbItems);
+            Map<String, Object> result = new HashMap<>();
+            result.put("ingredients", existing);
+            result.put("selectedCount", existing.size());
+            return result;
+        }
+
+        // 无历史选择:重新计算一批并持久化
         List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now());
-        selectedIngredients.put(familyId, suggestions);
+        persistSelectedIngredients(familyId, suggestions);
         refreshSeed.put(familyId, 0);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
-        result.put("selectedCount", 0);
+        result.put("selectedCount", suggestions.size());
         return result;
     }
 
@@ -39,61 +64,67 @@ public class DietIngredientService {
         Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
         refreshSeed.put(familyId, seed);
 
-        // 重新生成(通过 seed 偏移截取,真正换一批)
+        // 重新计算(含 seed 偏移)
         List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
-        selectedIngredients.put(familyId, suggestions);
+        // 清空并重新持久化
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId));
+        persistSelectedIngredients(familyId, suggestions);
 
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
-        result.put("selectedCount", 0);
+        result.put("selectedCount", suggestions.size());
         return result;
     }
 
     public Map<String, Object> addIngredient(Long familyId, Long foodId, String name) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        
-        // 检查是否已存在
-        boolean exists = selected.stream().anyMatch(f -> f.getFoodId() != null && f.getFoodId().equals(foodId));
-        if (!exists) {
-            IngredientRecommendation item = new IngredientRecommendation();
-            item.setFoodId(foodId);
-            item.setName(name);
-            item.setScore(50);
-            item.setReason("手动添加");
-            selected.add(item);
-            selectedIngredients.put(familyId, selected);
+        // 幂等插入
+        Integer count = selectedIngredientMapper.selectCount(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .eq(DietFamilySelectedIngredient::getFoodId, foodId));
+        if (count != null && count > 0) {
+            // 已存在,返回当前列表
+            return listIngredients(familyId);
         }
-        
-        Map<String, Object> result = new HashMap<>();
-        result.put("ingredients", selected);
-        result.put("selectedCount", selected.size());
-        return result;
+
+        DietFamilySelectedIngredient item = new DietFamilySelectedIngredient();
+        item.setFamilyId(familyId);
+        item.setFoodId(foodId);
+        item.setFoodName(name);
+        item.setScore(50);
+        item.setReason("手动添加");
+        item.setSortOrder(0);
+        selectedIngredientMapper.insert(item);
+
+        return listIngredients(familyId);
     }
 
     public Map<String, Object> removeIngredient(Long familyId, Long foodId) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        selected.removeIf(f -> f.getFoodId() != null && f.getFoodId().equals(foodId));
-        selectedIngredients.put(familyId, selected);
-        
-        Map<String, Object> result = new HashMap<>();
-        result.put("ingredients", selected);
-        result.put("selectedCount", selected.size());
-        return result;
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .eq(DietFamilySelectedIngredient::getFoodId, foodId));
+        return listIngredients(familyId);
     }
 
     public Map<String, Object> confirmIngredients(Long familyId, Map<String, Object> request) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        
+        List<IngredientRecommendation> selected = getFromDb(familyId);
+
         Map<String, Object> result = new HashMap<>();
         result.put("selectedFoods", selected);
         result.put("message", "食材已确认,正在生成食谱...");
-        
-        // 清空会话选择
-        selectedIngredients.remove(familyId);
-        
+
+        // 确认后将当前选中食材持久化保留,不删除(持久化语义)
+        // 前端如需清空,可调用 removeIngredient 或 refreshIngredients
         return result;
     }
 
+    // ============================================================
+    // 食材搜索
+    // ============================================================
+
     /**
      * 搜索食材(供"添加食材"弹窗使用)
      *
@@ -103,7 +134,7 @@ public class DietIngredientService {
     public Map<String, Object> searchFoods(String keyword) {
         List<Map<String, Object>> foods = new ArrayList<>();
         List<Food> all = foodMapper.selectList(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Food>()
+                new LambdaQueryWrapper<Food>()
                         .eq(Food::getStatus, "active")
                         .orderByAsc(Food::getSortOrder)
         );
@@ -123,4 +154,63 @@ public class DietIngredientService {
         result.put("foods", foods);
         return result;
     }
+
+    // ============================================================
+    // 内部辅助
+    // ============================================================
+
+    private List<IngredientRecommendation> getFromDb(Long familyId) {
+        return dbItemsToRecommendations(selectedIngredientMapper.selectList(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .orderByAsc(DietFamilySelectedIngredient::getSortOrder)));
+    }
+
+    private Map<String, Object> listIngredients(Long familyId) {
+        List<IngredientRecommendation> selected = getFromDb(familyId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("ingredients", selected);
+        result.put("selectedCount", selected.size());
+        return result;
+    }
+
+    /** 将 DB 实体转换为 IngredientRecommendation DTO */
+    private List<IngredientRecommendation> dbItemsToRecommendations(List<DietFamilySelectedIngredient> items) {
+        return items.stream().map(item -> {
+            IngredientRecommendation rec = new IngredientRecommendation();
+            rec.setFoodId(item.getFoodId());
+            rec.setName(item.getFoodName());
+            rec.setScore(item.getScore());
+            rec.setReason(item.getReason());
+            rec.setCategory(item.getCategory());
+            rec.setCaloriesPer100g(item.getCaloriesPer100g());
+            rec.setCookingLevel(item.getCookingLevel());
+            rec.setNutritionScore(item.getNutritionScore());
+            rec.setBalanceReason(item.getBalanceReason());
+            return rec;
+        }).collect(Collectors.toList());
+    }
+
+    /** 将推荐列表持久化到 DB */
+    private void persistSelectedIngredients(Long familyId, List<IngredientRecommendation> suggestions) {
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId));
+        for (int i = 0; i < suggestions.size(); i++) {
+            IngredientRecommendation s = suggestions.get(i);
+            DietFamilySelectedIngredient item = new DietFamilySelectedIngredient();
+            item.setFamilyId(familyId);
+            item.setFoodId(s.getFoodId());
+            item.setFoodName(s.getName());
+            item.setScore(s.getScore());
+            item.setReason(s.getReason());
+            item.setCategory(s.getCategory());
+            item.setCaloriesPer100g(s.getCaloriesPer100g());
+            item.setCookingLevel(s.getCookingLevel());
+            item.setNutritionScore(s.getNutritionScore());
+            item.setBalanceReason(s.getBalanceReason());
+            item.setSortOrder(i);
+            selectedIngredientMapper.insert(item);
+        }
+    }
 }

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

@@ -4080,6 +4080,27 @@ CREATE TABLE IF NOT EXISTS report_food_suitability (
     INDEX idx_food (food_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告食材适宜度';
 
+-- ============================================================
+-- 家庭共餐选中食材表(持久化共餐场景食材选择)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS diet_family_selected_ingredients (
+    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 '食材名称',
+    score INT COMMENT '推荐分',
+    reason VARCHAR(255) COMMENT '推荐理由',
+    category VARCHAR(50) COMMENT '分类',
+    calories_per_100g INT COMMENT '每100克卡路里',
+    cooking_level TINYINT COMMENT '烹饪难度(1=快手,2=中等,3=耗时)',
+    nutrition_score INT COMMENT '营养密度分(0-100)',
+    balance_reason VARCHAR(255) COMMENT '均衡化推荐理由',
+    sort_order INT DEFAULT 0 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='家庭共餐选中食材';
 
 -- ============================================================
 -- 积分兑换商品表(迁移124)