Ver Fonte

feat: 扩展 BeijingNutritionService(食材推荐方法)

iwt há 1 mês atrás
pai
commit
995c014a93

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

@@ -7960,5 +7960,153 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("创建user_data_permissions表失败(可能已存在): " + e.getMessage());
 		}
+
+		// 迁移174: family_members 添加饮食相关字段(饮食模块需求)
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_living_together TINYINT(1) DEFAULT 1 COMMENT '是否同住(0=不同住,不出现在食谱推荐中)'");
+			log.info("已添加is_living_together列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 is_living_together 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日早餐同餐'");
+			log.info("已添加meal_with_breakfast_weekday列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_breakfast_weekday 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日午餐同餐'");
+			log.info("已添加meal_with_lunch_weekday列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_lunch_weekday 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日晚餐同餐'");
+			log.info("已添加meal_with_dinner_weekday列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_dinner_weekday 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_breakfast_weekend TINYINT(1) DEFAULT 0 COMMENT '周末早餐同餐'");
+			log.info("已添加meal_with_breakfast_weekend列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_breakfast_weekend 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_lunch_weekend TINYINT(1) DEFAULT 0 COMMENT '周末午餐同餐'");
+			log.info("已添加meal_with_lunch_weekend列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_lunch_weekend 列失败(可能已存在): " + e.getMessage());
+		}
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN meal_with_dinner_weekend TINYINT(1) DEFAULT 0 COMMENT '周末晚餐同餐'");
+			log.info("已添加meal_with_dinner_weekend列到family_members表");
+		} catch (Exception e) {
+			log.warn("family_members 添加 meal_with_dinner_weekend 列失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移175: 创建 diet_preferences 表(饮食偏好调研表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_preferences (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"family_member_id BIGINT NOT NULL COMMENT '绑定家庭成员', " +
+				"allergies JSON COMMENT '过敏原列表', " +
+				"absolute_avoid JSON COMMENT '绝对忌口列表', " +
+				"religious_diet VARCHAR(50) DEFAULT 'none' COMMENT '宗教饮食', " +
+				"spice_level TINYINT DEFAULT NULL COMMENT '辣度 0-5', " +
+				"flavor_pref JSON COMMENT '口味偏好', " +
+				"cuisine_pref JSON COMMENT '菜系偏好', " +
+				"cooking_methods JSON COMMENT '烹饪方式偏好', " +
+				"health_goals JSON COMMENT '健康目标', " +
+				"goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend', " +
+				"goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认', " +
+				"filled_at TIMESTAMP NULL COMMENT '首次填写时间', " +
+				"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_member (family_member_id), " +
+				"INDEX idx_updated (updated_at)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食偏好调研表'");
+			log.info("已创建diet_preferences表");
+		} catch (Exception e) {
+			log.warn("创建 diet_preferences 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移176: 创建 meal_configs 表(共餐配置表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS meal_configs (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+				"config_date_type VARCHAR(20) NOT NULL COMMENT '工作日/周末/节假日', " +
+				"meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " +
+				"participant_member_ids JSON NOT NULL COMMENT '参与成员ID列表', " +
+				"notes VARCHAR(255) DEFAULT NULL COMMENT '备注', " +
+				"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_config (family_id, config_date_type, meal_type), " +
+				"INDEX idx_family (family_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='共餐配置表'");
+			log.info("已创建meal_configs表");
+		} catch (Exception e) {
+			log.warn("创建 meal_configs 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移177: 创建 diet_recommendations 表(食谱推荐方案表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_recommendations (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+				"recommendation_date DATE NOT NULL COMMENT '推荐日期', " +
+				"meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner', " +
+				"participant_member_ids JSON COMMENT '参与成员ID列表', " +
+				"menu_json MEDIUMTEXT NOT NULL COMMENT '菜品列表JSON', " +
+				"nutrition_summary JSON COMMENT '营养汇总', " +
+				"status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/adjusted/completed/skipped', " +
+				"version INT DEFAULT 1 COMMENT '版本号', " +
+				"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_family_date (family_id, recommendation_date), " +
+				"INDEX idx_meal (meal_type, recommendation_date)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食谱推荐方案表'");
+			log.info("已创建diet_recommendations表");
+		} catch (Exception e) {
+			log.warn("创建 diet_recommendations 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移178: 创建 diet_records 表(饮食记录表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_records (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+				"member_id BIGINT NOT NULL COMMENT '吃的成员ID', " +
+				"meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack', " +
+				"record_date DATE NOT NULL COMMENT '记录日期', " +
+				"record_method VARCHAR(20) DEFAULT NULL COMMENT 'photo/manual/from_recommendation', " +
+				"image_url VARCHAR(500) DEFAULT NULL COMMENT '原图OSS路径', " +
+				"ai_recognized_foods JSON DEFAULT NULL COMMENT 'AI识别结果', " +
+				"user_confirmed_foods JSON NOT NULL COMMENT '用户最终确认食材', " +
+				"notes VARCHAR(500) DEFAULT NULL COMMENT '备注', " +
+				"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_date (member_id, record_date), " +
+				"INDEX idx_family_date (family_id, record_date)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录表'");
+			log.info("已创建diet_records表");
+		} catch (Exception e) {
+			log.warn("创建 diet_records 表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移179: 创建 diet_record_items 表(饮食记录食材明细表)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS diet_record_items (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"record_id BIGINT NOT NULL COMMENT 'diet_records.id', " +
+				"food_name VARCHAR(100) NOT NULL COMMENT '食材名称', " +
+				"food_id BIGINT DEFAULT NULL COMMENT '关联foods表ID', " +
+				"confidence DECIMAL(3,2) DEFAULT NULL COMMENT 'AI置信度0-1', " +
+				"source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation', " +
+				"INDEX idx_record (record_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表'");
+			log.info("已创建diet_record_items表");
+		} catch (Exception e) {
+			log.warn("创建 diet_record_items 表失败(可能已存在): " + e.getMessage());
+		}
 	}
 }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/IngredientRecommendation.java

@@ -0,0 +1,12 @@
+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;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -110,6 +110,28 @@ public class FamilyMember implements Serializable {
 
     private Date updatedAt;
 
+    // ===== 饮食模块扩展字段 =====
+    /** 是否同住: 1=同住, 0=不同住(不同住成员不出现在食谱推荐中) */
+    private Integer isLivingTogether;
+
+    /** 工作日早餐同餐: 1=同餐 */
+    private Integer mealWithBreakfastWeekday;
+
+    /** 工作日午餐同餐: 1=同餐 */
+    private Integer mealWithLunchWeekday;
+
+    /** 工作日晚餐同餐: 1=同餐 */
+    private Integer mealWithDinnerWeekday;
+
+    /** 周末早餐同餐: 1=同餐 */
+    private Integer mealWithBreakfastWeekend;
+
+    /** 周末午餐同餐: 1=同餐 */
+    private Integer mealWithLunchWeekend;
+
+    /** 周末晚餐同餐: 1=同餐 */
+    private Integer mealWithDinnerWeekend;
+
     /**
      * 是否男性
      */

+ 168 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

@@ -3,13 +3,16 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.BeijingNutritionDTO.*;
+import com.etotem.cfc.dto.IngredientRecommendation;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.kb.BacteriaFoodMapping;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
 import com.etotem.cfc.mapper.FoodMapper;
 import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -38,6 +41,12 @@ public class BeijingNutritionService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    @Resource
+    private MealConfigMapper mealConfigMapper;
+
     // ============================================================
     // 核心推荐引擎
     // ============================================================
@@ -221,4 +230,163 @@ public class BeijingNutritionService {
         }
         return list;
     }
+
+    // ============================================================
+    // 饮食模块扩展
+    // ============================================================
+
+    /**
+     * 生成食材推荐列表(供饮食首页使用)
+     *
+     * @param familyId 家庭ID
+     * @param date     日期
+     * @return 推荐食材列表(Top 15,含推荐理由)
+     */
+    public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
+        List<IngredientRecommendation> result = new ArrayList<>();
+        
+        // 1. 查 meal_configs → participant_member_ids
+        LambdaQueryWrapper<com.etotem.cfc.entity.MealConfig> configWrapper = new LambdaQueryWrapper<>();
+        configWrapper.eq(com.etotem.cfc.entity.MealConfig::getFamilyId, familyId);
+        configWrapper.eq(com.etotem.cfc.entity.MealConfig::getConfigDateType, isWeekend(date) ? "weekend" : "weekday");
+        List<com.etotem.cfc.entity.MealConfig> mealConfigs = mealConfigMapper.selectList(configWrapper);
+        
+        if (mealConfigs.isEmpty()) {
+            log.warn("家庭 {} 未配置共餐信息", familyId);
+            return result;
+        }
+        
+        // 2. 收集所有参与成员
+        Set<Long> participantIds = new HashSet<>();
+        for (MealConfig config : mealConfigs) {
+            // 解析 participantMemberIds JSON
+            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()) {
+            return result;
+        }
+        
+        // 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);
+            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) {
+                        log.warn("解析 allergies 失败: {}", e.getMessage());
+                    }
+                }
+                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) {
+                        log.warn("解析 absoluteAvoid 失败: {}", e.getMessage());
+                    }
+                }
+            }
+            
+            // 查最近菌群报告
+            LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
+            reportWrapper.eq(HealthReport::getUserId, memberId)
+                         .eq(HealthReport::getReportType, "gut_flora")
+                         .orderByDesc(HealthReport::getReportDate)
+                         .last("LIMIT 1");
+            HealthReport report = healthReportMapper.selectOne(reportWrapper);
+            
+            if (report != null) {
+                LambdaQueryWrapper<HealthGutFlora> floraWrapper = new LambdaQueryWrapper<>();
+                floraWrapper.eq(HealthGutFlora::getReportId, report.getId());
+                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);
+            }
+        }
+        
+        // 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);
+        
+        for (Food food : allFoods) {
+            String foodName = food.getName();
+            
+            // 过滤禁忌食材
+            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);
+                }
+            }
+            
+            // 检查是否已在结果中
+            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);
+            }
+        }
+        
+        // 6. 排序并返回 Top 15
+        result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
+        return result.size() > 15 ? result.subList(0, 15) : result;
+    }
+    
+    private boolean isWeekend(java.time.LocalDate date) {
+        return date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY || 
+               date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+    }
 }

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

@@ -1964,6 +1964,14 @@ CREATE TABLE IF NOT EXISTS family_members (
     focus_max_daily TINYINT DEFAULT 3 COMMENT '每日专注上限',
     focus_remaining TINYINT DEFAULT 3 COMMENT '今日剩余专注次数',
     focus_reset_date DATE COMMENT '专注次数重置日期',
+    -- 饮食模块扩展字段
+    is_living_together TINYINT(1) DEFAULT 1 COMMENT '是否同住(0=不同住,不出现在食谱推荐中)',
+    meal_with_breakfast_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日早餐同餐',
+    meal_with_lunch_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日午餐同餐',
+    meal_with_dinner_weekday TINYINT(1) DEFAULT 0 COMMENT '工作日晚餐同餐',
+    meal_with_breakfast_weekend TINYINT(1) DEFAULT 0 COMMENT '周末早餐同餐',
+    meal_with_lunch_weekend TINYINT(1) DEFAULT 0 COMMENT '周末午餐同餐',
+    meal_with_dinner_weekend TINYINT(1) DEFAULT 0 COMMENT '周末晚餐同餐',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_family_id (family_id),
@@ -4168,3 +4176,84 @@ CREATE TABLE IF NOT EXISTS user_data_permissions (
     updated_at DATETIME,
     INDEX idx_user_perm (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据使用授权';
+
+-- ===== 饮食模块 =====
+
+-- 饮食偏好调研表(每成员一份)
+CREATE TABLE IF NOT EXISTS diet_preferences (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_member_id BIGINT NOT NULL COMMENT '绑定家庭成员',
+    allergies JSON COMMENT '过敏原列表',
+    absolute_avoid JSON COMMENT '绝对忌口列表',
+    religious_diet VARCHAR(50) DEFAULT 'none' COMMENT '宗教饮食:none/清真/素食/纯素/印度素',
+    spice_level TINYINT DEFAULT NULL COMMENT '辣度 0-5',
+    flavor_pref JSON COMMENT '口味偏好(酸/甜/咸/鲜/苦)',
+    cuisine_pref JSON COMMENT '菜系偏好(川/粤/淮扬/日料/西餐)',
+    cooking_methods JSON COMMENT '偏好烹饪方式(炒/蒸/煮/烤/凉拌)',
+    health_goals JSON COMMENT '健康目标(减脂/增肌/控糖/肠道调理/护肝)',
+    goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend',
+    goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认',
+    filled_at TIMESTAMP NULL COMMENT '首次填写时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_member (family_member_id),
+    INDEX idx_updated (updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食偏好调研表';
+
+-- 共餐配置表(按家庭+日期类型+餐次)
+CREATE TABLE IF NOT EXISTS meal_configs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    config_date_type VARCHAR(20) NOT NULL COMMENT '工作日/周末/节假日',
+    meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner',
+    participant_member_ids JSON NOT NULL COMMENT '参与成员ID列表',
+    notes VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_config (family_id, config_date_type, meal_type),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='共餐配置表';
+
+-- 食谱推荐方案表
+CREATE TABLE IF NOT EXISTS diet_recommendations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    recommendation_date DATE NOT NULL COMMENT '推荐日期',
+    meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner',
+    participant_member_ids JSON COMMENT '参与成员ID列表',
+    menu_json MEDIUMTEXT NOT NULL COMMENT '菜品列表JSON',
+    nutrition_summary JSON COMMENT '营养汇总',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/adjusted/completed/skipped',
+    version INT DEFAULT 1 COMMENT '版本号(重新生成+1)',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_family_date (family_id, recommendation_date),
+    INDEX idx_meal (meal_type, recommendation_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食谱推荐方案表';
+
+-- 饮食记录表
+CREATE TABLE IF NOT EXISTS diet_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    member_id BIGINT NOT NULL COMMENT '吃的成员ID',
+    meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack',
+    record_date DATE NOT NULL COMMENT '记录日期',
+    record_method VARCHAR(20) DEFAULT NULL COMMENT 'photo/manual/from_recommendation',
+    image_url VARCHAR(500) DEFAULT NULL COMMENT '原图OSS路径',
+    ai_recognized_foods JSON DEFAULT NULL COMMENT 'AI识别结果',
+    user_confirmed_foods JSON NOT NULL COMMENT '用户最终确认食材',
+    notes VARCHAR(500) DEFAULT NULL COMMENT '备注',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_member_date (member_id, record_date),
+    INDEX idx_family_date (family_id, record_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录表';
+
+-- 饮食记录食材明细表
+CREATE TABLE IF NOT EXISTS diet_record_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    record_id BIGINT NOT NULL COMMENT 'diet_records.id',
+    food_name VARCHAR(100) NOT NULL COMMENT '食材名称',
+    food_id BIGINT DEFAULT NULL COMMENT '关联foods表ID',
+    confidence DECIMAL(3,2) DEFAULT NULL COMMENT 'AI置信度0-1',
+    source VARCHAR(20) DEFAULT NULL COMMENT 'ai_recognized/manual/from_recommendation',
+    INDEX idx_record (record_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饮食记录食材明细表';