Просмотр исходного кода

feat: implement diet recommendation system (backend)

- Add 5 entities: Food, Recipe, SeasonalFood, MealLog, UserNutritionProfile
- Add 4 mappers: FoodMapper, RecipeMapper, SeasonalFoodMapper, MealLogMapper
- Add 5 services: FoodService, RecipeService, SeasonalFoodService, MealLogService, MealRecommendService
- Add 2 DTOs: RecommendationContext, MealRecommendResult
- Add MealRecommendController (/api/meal/*) for recipe recommendations, food replacement, meal logging
- Add 3 admin controllers: AdminFoodController, AdminRecipeController, AdminSeasonalController
- Add DDL for 4 new tables in DatabaseInitializer
- Fix: add matchChildIdByName to HealthReportService (needed for remote PDF parsing feature)
Sisyphus 3 месяцев назад
Родитель
Сommit
c53845c450
22 измененных файлов с 1023 добавлено и 0 удалено
  1. 123 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 140 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java
  3. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java
  4. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java
  5. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java
  6. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java
  7. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java
  8. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java
  9. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java
  10. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java
  11. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java
  12. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java
  13. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FoodMapper.java
  14. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MealLogMapper.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RecipeMapper.java
  16. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SeasonalFoodMapper.java
  17. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java
  18. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  19. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java
  20. 151 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java
  21. 42 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java
  22. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java

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

@@ -41,6 +41,9 @@ public class DatabaseInitializer implements CommandLineRunner {
         // 4. 初始化默认数据
         // 4. 初始化默认数据
         initializeDefaultData();
         initializeDefaultData();
 
 
+        // 5. 初始化食品相关表
+        initFoodTables();
+
         log.info("数据库初始化完成!");
         log.info("数据库初始化完成!");
     }
     }
 
 
@@ -2932,6 +2935,126 @@ try {
         }
         }
     }
     }
 
 
+    private void initFoodTables() {
+        log.info("开始初始化食品相关表...");
+
+        // 4张新表 (CREATE TABLE IF NOT EXISTS)
+        String[] ddl = {
+            "CREATE TABLE IF NOT EXISTS foods ("
+            + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+            + "name VARCHAR(100) NOT NULL COMMENT '食材名称', "
+            + "category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他', "
+            + "unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位', "
+            + "calories DECIMAL(8,2) COMMENT '热量(kcal/100g)', "
+            + "protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)', "
+            + "fat DECIMAL(8,2) COMMENT '脂肪(g/100g)', "
+            + "carbs DECIMAL(8,2) COMMENT '碳水(g/100g)', "
+            + "fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)', "
+            + "price_level TINYINT COMMENT '价格等级 1-5', "
+            + "nutrition_tags VARCHAR(500) COMMENT '营养标签JSON', "
+            + "suitable_for VARCHAR(200) COMMENT '适宜人群JSON', "
+            + "allergens VARCHAR(200) COMMENT '过敏原JSON', "
+            + "external_source VARCHAR(100) COMMENT '数据来源', "
+            + "external_id VARCHAR(100) COMMENT '外部ID', "
+            + "cover_image VARCHAR(500) COMMENT '食材图片', "
+            + "status VARCHAR(20) DEFAULT 'active', "
+            + "sort_order INT DEFAULT 0, "
+            + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+            + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+            + "INDEX idx_category (category), "
+            + "INDEX idx_price (price_level)"
+            + ");",
+
+            "CREATE TABLE IF NOT EXISTS recipes ("
+            + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+            + "name VARCHAR(200) NOT NULL COMMENT '食谱名称', "
+            + "meal_type VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐', "
+            + "description TEXT COMMENT '简介', "
+            + "ingredients TEXT NOT NULL COMMENT '食材清单JSON', "
+            + "steps TEXT COMMENT '烹饪步骤JSON', "
+            + "cook_time INT COMMENT '烹饪时间(分钟)', "
+            + "difficulty VARCHAR(20) COMMENT '难度: 简单/中等/困难', "
+            + "total_calories DECIMAL(8,2) COMMENT '总热量', "
+            + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
+            + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
+            + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
+            + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
+            + "suitable_for VARCHAR(200) COMMENT '适宜场景', "
+            + "nutrition_tags VARCHAR(500) COMMENT '营养标签', "
+            + "external_source VARCHAR(100), "
+            + "external_id VARCHAR(100), "
+            + "cover_image VARCHAR(500), "
+            + "status VARCHAR(20) DEFAULT 'active', "
+            + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+            + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+            + "INDEX idx_meal_type (meal_type), "
+            + "INDEX idx_cook_time (cook_time)"
+            + ");",
+
+            "CREATE TABLE IF NOT EXISTS seasonal_foods ("
+            + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+            + "food_id BIGINT NOT NULL COMMENT '关联foods.id', "
+            + "month TINYINT NOT NULL COMMENT '月份 1-12', "
+            + "region VARCHAR(50) DEFAULT '全国' COMMENT '产地', "
+            + "peak_flag TINYINT DEFAULT 0 COMMENT '盛产标志', "
+            + "sort_order INT DEFAULT 0, "
+            + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+            + "INDEX idx_food_month (food_id, month), "
+            + "INDEX idx_month (month)"
+            + ");",
+
+            "CREATE TABLE IF NOT EXISTS meal_logs ("
+            + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+            + "user_id BIGINT NOT NULL COMMENT '用户ID', "
+            + "child_id BIGINT COMMENT '孩子ID', "
+            + "meal_type VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐', "
+            + "meal_date DATE NOT NULL COMMENT '用餐日期', "
+            + "meal_time DATETIME COMMENT '具体时间', "
+            + "foods TEXT NOT NULL COMMENT '食物清单JSON', "
+            + "total_calories DECIMAL(8,2) COMMENT '总热量', "
+            + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
+            + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
+            + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
+            + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
+            + "note TEXT COMMENT '备注', "
+            + "images VARCHAR(1000) COMMENT '图片JSON', "
+            + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+            + "INDEX idx_user_date (user_id, meal_date), "
+            + "INDEX idx_child_date (child_id, meal_date)"
+            + ");"
+        };
+
+        for (String sql : ddl) {
+            try {
+                jdbcTemplate.execute(sql);
+            } catch (Exception e) {
+                log.warn("食品表DDL执行失败(可能已存在): {}", e.getMessage());
+            }
+        }
+
+        // 扩展 user_nutrition_profile(如果表不存在则跳过)
+        ensureColumn("user_nutrition_profile", "budget_monthly", "INT COMMENT '月食品预算(元)'");
+        ensureColumn("user_nutrition_profile", "family_taste_preferences", "TEXT COMMENT '家庭成员口味偏好JSON'");
+        ensureColumn("user_nutrition_profile", "cuisine_style", "VARCHAR(100) COMMENT '偏好菜系'");
+        ensureColumn("user_nutrition_profile", "meal_count", "INT DEFAULT 3 COMMENT '每日餐数'");
+        ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced'");
+
+        log.info("食品相关表初始化完成");
+    }
+
+    private void ensureColumn(String table, String column, String definition) {
+        try {
+            jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
+            log.info("已添加列 {}.{}", table, column);
+        } catch (Exception e) {
+            if (e.getMessage() != null && e.getMessage().contains("Duplicate column")) {
+                log.info("列 {}.{} 已存在,跳过", table, column);
+            } else {
+                log.warn("添加列 {}.{} 失败: {}", table, column, e.getMessage());
+            }
+        }
+    }
+
     private void insertProductSeed(String name, String desc, String type,
     private void insertProductSeed(String name, String desc, String type,
                                 Integer price,
                                 Integer price,
                                 String domain, String memberEligible) {
                                 String domain, String memberEligible) {

+ 140 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java

@@ -0,0 +1,140 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RecommendationContext;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.MealLog;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.MealLogService;
+import com.etotem.cfc.service.MealRecommendService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Tag(name = "\u98df\u8c31\u63a8\u8350", description = "\u4e2a\u6027\u5316\u98df\u8c31\u63a8\u8350\u4e0e\u996e\u98df\u8bb0\u5f55")
+@Slf4j
+@RestController
+@RequestMapping("/api/meal")
+public class MealRecommendController {
+
+    @Resource
+    private MealRecommendService mealRecommendService;
+
+    @Resource
+    private MealLogService mealLogService;
+
+    @Resource
+    private AIService aiService;
+
+    @Operation(summary = "\u83b7\u53d6\u98df\u8c31\u63a8\u8350")
+    @PostMapping("/recommend")
+    public Result<Map<String, Object>> recommend(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        try {
+            RecommendationContext ctx = mealRecommendService.aggregateContext(userId);
+            if (ctx.getCandidateFoods().isEmpty() && ctx.getCandidateRecipes().isEmpty()) {
+                return Result.error("\u6682\u65e0\u53ef\u7528\u98df\u6750\u548c\u98df\u8c31\uff0c\u8bf7\u8054\u7cfb\u8fd0\u8425\u914d\u7f6e");
+            }
+
+            String mealType = (String) params.get("mealType");
+            String query = "\u8bf7\u6839\u636e\u6211\u7684\u5065\u5eb7\u60c5\u51b5\u548c\u5019\u9009\u98df\u6750\uff0c\u751f\u6210\u4e00\u4efd\u4e2a\u6027\u5316\u7684"
+                    + (mealType != null ? mealType + " " : "\u6bcf\u65e5")
+                    + "\u98df\u8c31\u65b9\u6848\u3002\u683c\u5f0f\u8981\u6c42\uff1a\u8fd4\u56deJSON\u5305\u542bdailyMeals\u6570\u7ec4\uff08\u6bcf\u9910\u542brecipeName/reason/ingredients/tips\uff09\u3001forbiddenHint\u3001nutritionTip\u3002";
+            String conversationId = (String) params.get("conversationId");
+
+            Map<String, Object> inputs = mealRecommendService.buildDifyInputs(userId);
+            Map<String, Object> difyResp = aiService.sendNutritionMessage(
+                    query, String.valueOf(userId), conversationId, inputs);
+            String answer = (String) difyResp.getOrDefault("answer", "");
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("answer", answer);
+            result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+            result.put("replaceOptions", ctx.getCandidateFoods().size() > 5
+                    ? ctx.getCandidateFoods().subList(0, 5) : ctx.getCandidateFoods());
+            result.put("nutritionSummary", ctx.getNutritionSummary());
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("\u98df\u8c31\u63a8\u8350\u5931\u8d25: userId={}", userId, e);
+            return Result.error("\u98df\u8c31\u63a8\u8350\u5931\u8d25: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "\u4e00\u56de\u6362\u4e00\u83dc \u2014 \u66ff\u6362\u6307\u5b9a\u98df\u6750")
+    @PostMapping("/replace")
+    public Result<Food> replace(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long foodId = params.get("foodId") != null
+                ? Long.valueOf(params.get("foodId").toString()) : null;
+        if (foodId == null) {
+            return Result.error("foodId\u4e0d\u80fd\u4e3a\u7a7a");
+        }
+        Food replacement = mealRecommendService.findReplaceCandidate(foodId, userId);
+        if (replacement == null) {
+            return Result.error("\u6ca1\u6709\u53ef\u66ff\u6362\u7684\u98df\u6750");
+        }
+        return Result.success(replacement);
+    }
+
+    @Operation(summary = "\u8bb0\u5f55\u996e\u98df\u65e5\u5fd7")
+    @PostMapping("/log")
+    public Result<Map<String, Object>> createLog(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        MealLog logEntry = new MealLog();
+        logEntry.setUserId(userId);
+        if (params.get("childId") != null) {
+            logEntry.setChildId(Long.valueOf(params.get("childId").toString()));
+        }
+        logEntry.setMealType((String) params.get("mealType"));
+        logEntry.setFoods(params.get("foods") != null ? params.get("foods").toString() : "[]");
+        logEntry.setNote((String) params.get("note"));
+
+        Object dateObj = params.get("mealDate");
+        if (dateObj != null) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                logEntry.setMealDate(sdf.parse(dateObj.toString()));
+            } catch (Exception e) {
+                logEntry.setMealDate(new Date());
+            }
+        } else {
+            logEntry.setMealDate(new Date());
+        }
+
+        mealLogService.create(logEntry);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", logEntry.getId());
+        result.put("message", "\u8bb0\u5f55\u6210\u529f");
+        return Result.success(result);
+    }
+
+    @Operation(summary = "\u83b7\u53d6\u8fd1\u671f\u996e\u98df\u65e5\u5fd7")
+    @PostMapping("/logs")
+    public Result<List<MealLog>> getLogs(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        int days = params.get("days") != null
+                ? Integer.parseInt(params.get("days").toString()) : 7;
+        return Result.success(mealLogService.getRecentMeals(userId, days));
+    }
+
+    @Operation(summary = "\u83b7\u53d6\u8425\u517b\u6444\u5165\u7edf\u8ba1")
+    @PostMapping("/nutrition-summary")
+    public Result<Map<String, Object>> nutritionSummary(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        int days = params.get("days") != null
+                ? Integer.parseInt(params.get("days").toString()) : 7;
+        return Result.success(mealLogService.getNutritionSummary(userId, days));
+    }
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.service.FoodService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "\u7ba1\u7406\u7aef-\u98df\u6750\u7ba1\u7406")
+@RestController
+@RequestMapping("/api/admin/foods")
+public class AdminFoodController {
+
+    @Resource
+    private FoodService foodService;
+
+    @Operation(summary = "\u98df\u6750\u5217\u8868")
+    @PostMapping("/list")
+    public Result<List<Food>> list(@RequestBody Map<String, Object> params) {
+        String category = (String) params.get("category");
+        return Result.success(foodService.listAll(category, "active"));
+    }
+
+    @Operation(summary = "\u65b0\u589e\u98df\u6750")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Food food) {
+        foodService.create(food);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "\u66f4\u65b0\u98df\u6750")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Food food) {
+        foodService.update(food);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "\u5220\u9664\u98df\u6750")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id\u4e0d\u80fd\u4e3a\u7a7a");
+        foodService.delete(id);
+        return Result.success("ok");
+    }
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.service.RecipeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "\u7ba1\u7406\u7aef-\u98df\u8c31\u7ba1\u7406")
+@RestController
+@RequestMapping("/api/admin/recipes")
+public class AdminRecipeController {
+
+    @Resource
+    private RecipeService recipeService;
+
+    @Operation(summary = "\u98df\u8c31\u5217\u8868")
+    @PostMapping("/list")
+    public Result<List<Recipe>> list(@RequestBody Map<String, Object> params) {
+        String mealType = (String) params.get("mealType");
+        return Result.success(recipeService.listByMealType(mealType));
+    }
+
+    @Operation(summary = "\u65b0\u589e\u98df\u8c31")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Recipe recipe) {
+        recipeService.create(recipe);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "\u66f4\u65b0\u98df\u8c31")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Recipe recipe) {
+        recipeService.update(recipe);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "\u5220\u9664\u98df\u8c31")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id\u4e0d\u80fd\u4e3a\u7a7a");
+        recipeService.delete(id);
+        return Result.success("ok");
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SeasonalFood;
+import com.etotem.cfc.service.SeasonalFoodService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "\u7ba1\u7406\u7aef-\u5e94\u5b63\u98df\u6750\u914d\u7f6e")
+@RestController
+@RequestMapping("/api/admin/seasonal")
+public class AdminSeasonalController {
+
+    @Resource
+    private SeasonalFoodService seasonalFoodService;
+
+    @Operation(summary = "\u5e94\u5b63\u5217\u8868")
+    @PostMapping("/list")
+    public Result<List<SeasonalFood>> list(@RequestBody Map<String, Object> params) {
+        Integer month = params.get("month") != null
+                ? Integer.valueOf(params.get("month").toString()) : null;
+        if (month != null) {
+            return Result.success(seasonalFoodService.listByMonth(month));
+        }
+        return Result.success(seasonalFoodService.listByMonth(LocalDate.now().getMonthValue()));
+    }
+
+    @Operation(summary = "\u65b0\u589e\u5e94\u5b63\u6620\u5c04")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody SeasonalFood seasonalFood) {
+        seasonalFoodService.create(seasonalFood);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "\u5220\u9664\u5e94\u5b63\u6620\u5c04")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id\u4e0d\u80fd\u4e3a\u7a7a");
+        seasonalFoodService.delete(id);
+        return Result.success("ok");
+    }
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.entity.Food;
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class MealRecommendResult {
+    private String weekPlan;
+    private Map<String, Object> nutritionSummary;
+    private List<Food> replaceOptions;
+    private String forbiddenHint;
+    private String nutritionTip;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.entity.*;
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class RecommendationContext {
+    private Long userId;
+    private HealthReport latestReport;
+    private List<HealthIndicator> abnormalIndicators;
+    private Integer budgetMonthly;
+    private String tastePreference;
+    private String dietaryRestrictions;
+    private String cuisineStyle;
+    private Integer cookingAbility;
+    private String familyTastePreferences;
+    private Map<String, Object> nutritionSummary;
+    private List<Food> candidateFoods;
+    private List<Recipe> candidateRecipes;
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java

@@ -0,0 +1,35 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("foods")
+public class Food implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String category;
+    private String unit;
+    private BigDecimal calories;
+    private BigDecimal protein;
+    private BigDecimal fat;
+    private BigDecimal carbs;
+    private BigDecimal fiber;
+    private Integer priceLevel;
+    private String nutritionTags;
+    private String suitableFor;
+    private String allergens;
+    private String externalSource;
+    private String externalId;
+    private String coverImage;
+    private String status;
+    private Integer sortOrder;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java

@@ -0,0 +1,30 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("meal_logs")
+public class MealLog implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long childId;
+    private String mealType;
+    private Date mealDate;
+    private Date mealTime;
+    private String foods;
+    private BigDecimal totalCalories;
+    private BigDecimal totalProtein;
+    private BigDecimal totalFat;
+    private BigDecimal totalCarbs;
+    private BigDecimal totalFiber;
+    private String note;
+    private String images;
+    private Date createdAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java

@@ -0,0 +1,36 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("recipes")
+public class Recipe implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String mealType;
+    private String description;
+    private String ingredients;
+    private String steps;
+    private Integer cookTime;
+    private String difficulty;
+    private BigDecimal totalCalories;
+    private BigDecimal totalProtein;
+    private BigDecimal totalFat;
+    private BigDecimal totalCarbs;
+    private BigDecimal totalFiber;
+    private String suitableFor;
+    private String nutritionTags;
+    private String externalSource;
+    private String externalId;
+    private String coverImage;
+    private String status;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java

@@ -0,0 +1,21 @@
+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("seasonal_foods")
+public class SeasonalFood implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long foodId;
+    private Integer month;
+    private String region;
+    private Integer peakFlag;
+    private Integer sortOrder;
+    private Date createdAt;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java

@@ -0,0 +1,39 @@
+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("user_nutrition_profile")
+public class UserNutritionProfile implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String fruitAllergy;
+
+    private String fruitPreferences;
+
+    private String questionnaireStatus;
+
+    private Integer budgetMonthly;
+
+    private String familyTastePreferences;
+
+    private String cuisineStyle;
+
+    private Integer mealCount;
+
+    private String cookingAbility;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

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

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

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

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

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

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

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class FoodService {
+
+    @Resource
+    private FoodMapper foodMapper;
+
+    public List<Food> listAll(String category, String status) {
+        LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<Food>()
+                .eq(status != null, Food::getStatus, status)
+                .eq(category != null, Food::getCategory, category)
+                .orderByAsc(Food::getSortOrder);
+        return foodMapper.selectList(wrapper);
+    }
+
+    public List<Food> listByPriceLevel(int maxLevel) {
+        return foodMapper.selectList(
+                new LambdaQueryWrapper<Food>()
+                        .eq(Food::getStatus, "active")
+                        .le(Food::getPriceLevel, maxLevel)
+        );
+    }
+
+    public Food getById(Long id) {
+        return foodMapper.selectById(id);
+    }
+
+    public void create(Food food) {
+        foodMapper.insert(food);
+    }
+
+    public void update(Food food) {
+        foodMapper.updateById(food);
+    }
+
+    public void delete(Long id) {
+        foodMapper.deleteById(id);
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -439,4 +439,18 @@ public class HealthReportService {
 
 
         return false;
         return false;
     }
     }
+
+    public Long matchChildIdByName(String name, Long familyId) {
+        if (name == null || familyId == null) return null;
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getFamilyId, familyId)
+        );
+        for (Child c : children) {
+            if (name.contains(c.getNickname()) || (c.getNickname() != null && c.getNickname().contains(name))) {
+                return c.getId();
+            }
+        }
+        return null;
+    }
 }
 }

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.MealLog;
+import com.etotem.cfc.mapper.MealLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.*;
+
+@Slf4j
+@Service
+public class MealLogService {
+
+    @Resource
+    private MealLogMapper mealLogMapper;
+
+    public MealLog create(MealLog logEntry) {
+        mealLogMapper.insert(logEntry);
+        return logEntry;
+    }
+
+    public List<MealLog> getRecentMeals(Long userId, int days) {
+        LocalDate since = LocalDate.now().minusDays(days);
+        return mealLogMapper.selectList(
+                new LambdaQueryWrapper<MealLog>()
+                        .eq(MealLog::getUserId, userId)
+                        .ge(MealLog::getMealDate, java.sql.Date.valueOf(since))
+                        .orderByDesc(MealLog::getMealDate)
+        );
+    }
+
+    public Map<String, Object> getNutritionSummary(Long userId, int days) {
+        List<MealLog> logs = getRecentMeals(userId, days);
+
+        BigDecimal sumCalories = BigDecimal.ZERO;
+        BigDecimal sumProtein = BigDecimal.ZERO;
+        BigDecimal sumFat = BigDecimal.ZERO;
+        BigDecimal sumCarbs = BigDecimal.ZERO;
+        BigDecimal sumFiber = BigDecimal.ZERO;
+
+        for (MealLog log : logs) {
+            if (log.getTotalCalories() != null) sumCalories = sumCalories.add(log.getTotalCalories());
+            if (log.getTotalProtein() != null) sumProtein = sumProtein.add(log.getTotalProtein());
+            if (log.getTotalFat() != null) sumFat = sumFat.add(log.getTotalFat());
+            if (log.getTotalCarbs() != null) sumCarbs = sumCarbs.add(log.getTotalCarbs());
+            if (log.getTotalFiber() != null) sumFiber = sumFiber.add(log.getTotalFiber());
+        }
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        int dayCount = Math.max(days, 1);
+        summary.put("days", logs.isEmpty() ? 0 : days);
+        summary.put("avgCalories", logs.isEmpty() ? 0 : sumCalories.divide(BigDecimal.valueOf(dayCount), 0, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgProtein", logs.isEmpty() ? 0 : sumProtein.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgFat", logs.isEmpty() ? 0 : sumFat.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgCarbs", logs.isEmpty() ? 0 : sumCarbs.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgFiber", logs.isEmpty() ? 0 : sumFiber.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("totalMeals", logs.size());
+
+        return summary;
+    }
+
+    public void delete(Long id) {
+        mealLogMapper.deleteById(id);
+    }
+}

+ 151 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java

@@ -0,0 +1,151 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.MealRecommendResult;
+import com.etotem.cfc.dto.RecommendationContext;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class MealRecommendService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private FoodService foodService;
+
+    @Resource
+    private RecipeService recipeService;
+
+    @Resource
+    private SeasonalFoodService seasonalFoodService;
+
+    @Resource
+    private MealLogService mealLogService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    public RecommendationContext aggregateContext(Long userId) {
+        RecommendationContext ctx = new RecommendationContext();
+        ctx.setUserId(userId);
+
+        HealthReport report = healthReportService.getLatestReport(userId);
+        ctx.setLatestReport(report);
+        if (report != null) {
+            ctx.setAbnormalIndicators(
+                healthReportService.getReportIndicators(report.getId()).stream()
+                    .filter(i -> i.getStatus() != null && !"\u6b63\u5e38".equals(i.getStatus()))
+                    .collect(Collectors.toList()
+            ));
+        }
+
+        ctx.setBudgetMonthly(2000);
+        ctx.setTastePreference("\u6e05\u6de1");
+        ctx.setDietaryRestrictions("\u65e0");
+        ctx.setCuisineStyle("\u4e2d\u5f0f");
+
+        ctx.setNutritionSummary(mealLogService.getNutritionSummary(userId, 7));
+        filterCandidates(ctx);
+
+        return ctx;
+    }
+
+    private void filterCandidates(RecommendationContext ctx) {
+        int maxPriceLevel = ctx.getBudgetMonthly() != null
+                ? Math.max(1, Math.min(5, ctx.getBudgetMonthly() / 500))
+                : 1;
+
+        List<Long> seasonalIds = seasonalFoodService.getCurrentMonthFoodIds();
+        List<Food> allActive = foodService.listAll(null, "active");
+        List<Food> candidates = allActive.stream()
+                .filter(f -> seasonalIds.contains(f.getId()))
+                .filter(f -> f.getPriceLevel() != null && f.getPriceLevel() <= maxPriceLevel)
+                .collect(Collectors.toList());
+
+        ctx.setCandidateFoods(candidates.size() > 30 ? candidates.subList(0, 30) : candidates);
+
+        List<Recipe> recipeCandidates = recipeService.listByMealType(null);
+        ctx.setCandidateRecipes(recipeCandidates.size() > 15 ? recipeCandidates.subList(0, 15) : recipeCandidates);
+    }
+
+    public Food findReplaceCandidate(Long currentFoodId, Long userId) {
+        RecommendationContext ctx = aggregateContext(userId);
+        Food current = foodService.getById(currentFoodId);
+        if (current == null) return null;
+
+        return ctx.getCandidateFoods().stream()
+                .filter(f -> !f.getId().equals(currentFoodId))
+                .filter(f -> current.getCategory() == null || current.getCategory().equals(f.getCategory()))
+                .filter(f -> f.getPriceLevel() != null && current.getPriceLevel() != null
+                        && Math.abs(f.getPriceLevel() - current.getPriceLevel()) <= 1)
+                .findAny()
+                .orElse(null);
+    }
+
+    public Map<String, Object> buildDifyInputs(Long userId) {
+        RecommendationContext ctx = aggregateContext(userId);
+        Map<String, Object> inputs = new LinkedHashMap<>();
+
+        Map<String, Object> userProfile = new LinkedHashMap<>();
+        userProfile.put("taste", ctx.getTastePreference());
+        userProfile.put("cuisine", ctx.getCuisineStyle());
+        userProfile.put("budget", ctx.getBudgetMonthly());
+        userProfile.put("dietaryRestrictions", ctx.getDietaryRestrictions());
+        inputs.put("user_profile", userProfile);
+
+        if (ctx.getLatestReport() != null) {
+            Map<String, Object> healthSummary = new LinkedHashMap<>();
+            HealthReport r = ctx.getLatestReport();
+            healthSummary.put("overallScore", r.getOverallScore());
+            healthSummary.put("gutType", r.getGutType());
+            healthSummary.put("abnormalIndicators",
+                ctx.getAbnormalIndicators() != null
+                    ? ctx.getAbnormalIndicators().stream().map(i -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("name", i.getIndicatorName());
+                        m.put("value", i.getIndicatorValue());
+                        m.put("status", i.getStatus());
+                        m.put("refRange", i.getRefRange());
+                        return m;
+                    }).collect(Collectors.toList())
+                    : new ArrayList<>());
+            inputs.put("health_summary", healthSummary);
+        }
+
+        inputs.put("recent_intake", ctx.getNutritionSummary() != null ? ctx.getNutritionSummary() : new HashMap<>());
+
+        inputs.put("candidate_foods",
+            ctx.getCandidateFoods().stream()
+                .map(f -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("name", f.getName());
+                    m.put("category", f.getCategory());
+                    m.put("priceLevel", f.getPriceLevel());
+                    m.put("nutritionTags", f.getNutritionTags());
+                    return m;
+                })
+                .collect(Collectors.toList()));
+
+        inputs.put("candidate_recipes",
+            ctx.getCandidateRecipes().stream()
+                .map(r -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("name", r.getName());
+                    m.put("mealType", r.getMealType());
+                    m.put("cookTime", r.getCookTime());
+                    m.put("nutritionTags", r.getNutritionTags());
+                    return m;
+                })
+                .collect(Collectors.toList()));
+
+        return inputs;
+    }
+}

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.mapper.RecipeMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class RecipeService {
+
+    @Resource
+    private RecipeMapper recipeMapper;
+
+    public List<Recipe> listByMealType(String mealType) {
+        return recipeMapper.selectList(
+                new LambdaQueryWrapper<Recipe>()
+                        .eq(Recipe::getStatus, "active")
+                        .eq(mealType != null, Recipe::getMealType, mealType)
+        );
+    }
+
+    public Recipe getById(Long id) {
+        return recipeMapper.selectById(id);
+    }
+
+    public void create(Recipe recipe) {
+        recipeMapper.insert(recipe);
+    }
+
+    public void update(Recipe recipe) {
+        recipeMapper.updateById(recipe);
+    }
+
+    public void delete(Long id) {
+        recipeMapper.deleteById(id);
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.SeasonalFood;
+import com.etotem.cfc.mapper.SeasonalFoodMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class SeasonalFoodService {
+
+    @Resource
+    private SeasonalFoodMapper seasonalFoodMapper;
+
+    public List<Long> getCurrentMonthFoodIds() {
+        int month = LocalDate.now().getMonthValue();
+        return getFoodIdsByMonth(month);
+    }
+
+    public List<Long> getFoodIdsByMonth(int month) {
+        return seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getMonth, month)
+                        .orderByDesc(SeasonalFood::getPeakFlag)
+        ).stream().map(SeasonalFood::getFoodId).distinct().collect(Collectors.toList());
+    }
+
+    public List<SeasonalFood> listByMonth(int month) {
+        return seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getMonth, month)
+                        .orderByDesc(SeasonalFood::getPeakFlag)
+        );
+    }
+
+    public void create(SeasonalFood sf) {
+        seasonalFoodMapper.insert(sf);
+    }
+
+    public void delete(Long id) {
+        seasonalFoodMapper.deleteById(id);
+    }
+}