Переглянути джерело

docs(diet): 饮食页面换一批/生成食谱端到端修复实现计划

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 2 тижнів тому
батько
коміт
e9bce3010b
1 змінених файлів з 756 додано та 0 видалено
  1. 756 0
      docs/superpowers/plans/2026-09-01-diet-recipe-fix.md

+ 756 - 0
docs/superpowers/plans/2026-09-01-diet-recipe-fix.md

@@ -0,0 +1,756 @@
+# 饮食页面「换一批」&「生成食谱」端到端修复 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让饮食首页「🔄 换一批」与「✨ 生成食谱」、食谱推荐页「生成食谱/重新生成」真实可用:后端真正使用用户所选食材与共餐参与者生成 AI 菜单,前端正确解析 Result 结构与 LangGraph 菜单数据契约,并修复「添加食材」弹窗搜索空壳与按钮禁用态。
+
+**Architecture:** 后端(`DietRecommendationService` / `DietIngredientService` / `BeijingNutritionService` / `DietIngredientController`)补齐真实逻辑:解析 `selected_foods`、查询 `meal_configs` 参与者、给 `generateIngredientList` 传 seed 实现换一批、把 LangGraph 返回的菜单 JSON 归一化为前端期望结构并聚合营养汇总、新增食材搜索接口。前端(`index.vue` / `recommendation.vue`)修复 `res.data.*` 取值、实现弹窗搜索、同步 `selectedIngredients`。
+
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue 2(Options API)微信小程序 / FastAPI LangGraph(仅消费,不改)
+
+**Spec:**
+- `cfc-backend/docs/superpowers/specs/2026-08-06-diet-module-design.md`(数据模型/接口/流程契约)
+- `cfc-frontend/docs/superpowers/specs/2026-08-28-diet-redesign.md`(仅样式,本次不改视觉)
+
+## Global Constraints
+
+- 后端接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- DI 用 `@Resource`,字段名与类型默认 Bean Name 一致
+- 响应统一 `Result<T>`(code/message/data)
+- 新增接口前查 `docs/superpowers/api/API_REFERENCE.md`,确认无重复,新增后同步记录
+- 后端验证唯一方式:`mvn clean compile`(在 `cfc-backend/` 下)
+- 前端验证:`node -e` 提取 `.vue` 的 `<script>` 块做语法校验(**禁止** `npm run build:mp-weixin`,打包由用户 HBuilderX 完成)
+- 前端禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid、禁止 `:key` 表达式、禁止中文类名、禁止 `new Date(string)`(用 `parseDate()`)、Vue 2 Options API
+- 前端 `request` 包装器 resolve 的是整个 `Result` 对象 `{code, message, data}`——所有业务字段在 `res.data.*` 下
+- LangGraph 菜单契约(后端已实现,不改 Python):`{meals:[{type, name, dishes:[{name, ingredients:[{name, grams}], cooking_method, nutrition:{calories,protein,carbs,fat}, notes}]}]}`
+- 前端 `parseMenu` 期望(保持不变,由后端归一化):`{meals:[{type, name, participants, dishes:[{name, calories, ingredients:[{name, amount}], method}]}]}`
+
+---
+
+## 文件结构
+
+| 文件 | 职责 | 变更类型 |
+|------|------|----------|
+| `cfc-backend/.../service/BeijingNutritionService.java` | `generateIngredientList` 加 seed 参数,换一批真正不同 | Modify |
+| `cfc-backend/.../service/DietIngredientService.java` | `refreshIngredients` 传 seed;新增 `searchFoods(keyword)` | Modify |
+| `cfc-backend/.../controller/diet/DietIngredientController.java` | 新增 `POST /api/diet/ingredients/search` | Modify |
+| `cfc-backend/.../service/DietRecommendationService.java` | `generateRecommendation` 解析食材/参与者/调 AI/归一化菜单/聚合营养 | Modify |
+| `cfc-frontend/pages/diet/index.vue` | `generateRecipe` 取值、`searchFood` 实现、`selectedIngredients` 同步、`addFood` | Modify |
+| `cfc-frontend/pages/diet/recommendation.vue` | `loadRecommendation`/`generateRecipe` 用 `res.data.*` | Modify |
+| `cfc-frontend/utils/api.js` | 新增 `searchDietFoods(keyword)` | Modify |
+| `docs/superpowers/api/API_REFERENCE.md` | 记录新增搜索接口 | Modify |
+
+> 后端两个 Service + 一个 Controller 相互独立可并行;前端两个页面相互独立可并行。同一文件多处改动时避免并发冲突,按任务 commit 边界操作。
+
+## 全局校验命令(每个任务提交前必跑)
+
+```bash
+# 1) 后端编译(在 cfc-backend 下)
+mvn clean compile
+
+# 2) 前端 .vue script 语法校验(在 cfc-frontend 下)
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync(process.argv[1],'utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+if(!m){console.log('no script');process.exit(0)}
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+" pages/diet/index.vue
+
+# 3) 前端禁止模式扫描(输出应为空)
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue pages/diet/recommendation.vue
+```
+
+---
+
+## Task 1: 后端「换一批」seed 生效 + 食材搜索接口
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java`(`generateIngredientList` 签名)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java`
+
+**Interfaces:**
+- Consumes: `IngredientRecommendation`(foodId/name/reason/score/category)、`FoodService.listAll` 或 `FoodMapper`
+- Produces:
+  - `List<IngredientRecommendation> generateIngredientList(Long familyId, LocalDate date, int seed)`(重载保持旧签名 `(familyId, date)` 兼容)
+  - `Map<String, Object> refreshIngredients(Long familyId)`(返回 `{ingredients, selectedCount}`)
+  - `Map<String, Object> searchFoods(String keyword)`(返回 `{foods:[{id,name,category}]}`)
+  - `POST /api/diet/ingredients/search` body `{keyword}`
+
+- [ ] **步骤 1:`generateIngredientList` 加 seed 参数并实现偏移截取**
+
+`BeijingNutritionService.java` 现有 `generateIngredientList(Long familyId, LocalDate date)`(第 561 行)。改造:
+
+```java
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
+    return generateIngredientList(familyId, date, 0);
+}
+
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
+    // 现有逻辑不变……(查 meal_configs → 参与者 → 禁忌 → 打分 → 排序)
+    // 仅将方法体末尾的截取逻辑替换为:
+    // 6. 排序后按 seed 偏移旋转再截取 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);
+        }
+    }
+    return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
+}
+```
+
+注意:`Collections.rotate(list, distance)` 会就地旋转;旋转后再取前 15,可让每次换一批取到不同起始位置的食材组合。
+
+- [ ] **步骤 2:`DietIngredientService.refreshIngredients` 传 seed**
+
+`DietIngredientService.java` 第 33-45 行,改为:
+
+```java
+public Map<String, Object> refreshIngredients(Long familyId) {
+    Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
+    refreshSeed.put(familyId, seed);
+
+    // 重新生成(通过 seed 偏移截取,真正换一批)
+    List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
+    selectedIngredients.put(familyId, suggestions);
+
+    Map<String, Object> result = new HashMap<>();
+    result.put("ingredients", suggestions);
+    result.put("selectedCount", 0);
+    return result;
+}
+```
+
+(`suggestIngredients` 保持调用无参重载 `generateIngredientList(familyId, LocalDate.now())` 即可。)
+
+- [ ] **步骤 3:`DietIngredientService` 新增 `searchFoods`**
+
+在 `DietIngredientService.java` 注入 `FoodMapper`(或复用现有 service)并新增方法。需要先确认注入方式——若 `BeijingNutritionService` 已有 `foodMapper`,本类新增注入:
+
+```java
+@Resource
+private com.etotem.cfc.mapper.FoodMapper foodMapper;
+
+public Map<String, Object> searchFoods(String keyword) {
+    List<Map<String, Object>> foods = new ArrayList<>();
+    List<com.etotem.cfc.entity.Food> all = foodMapper.selectList(
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.Food>()
+                    .eq(com.etotem.cfc.entity.Food::getStatus, "active")
+                    .orderByAsc(com.etotem.cfc.entity.Food::getSortOrder)
+    );
+    String kw = keyword == null ? "" : keyword.trim();
+    int count = 0;
+    for (com.etotem.cfc.entity.Food f : all) {
+        if (kw.isEmpty() || (f.getName() != null && f.getName().contains(kw))) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", f.getId());
+            item.put("name", f.getName());
+            item.put("category", f.getCategory());
+            foods.add(item);
+            if (++count >= 50) break; // 限制 50 条
+        }
+    }
+    Map<String, Object> result = new HashMap<>();
+    result.put("foods", foods);
+    return result;
+}
+```
+
+> 若 `Food` 实体已有 `status`/`sortOrder` 字段(已确认存在),无需迁移。全量查询仅 50 条上限,内存过滤,符合"食材库不大"的现状。
+
+- [ ] **步骤 4:`DietIngredientController` 新增搜索接口**
+
+```java
+@PostMapping("/search")
+public Result<Map> searchFoods(@RequestBody(required = false) Map<String, Object> request) {
+    String keyword = request != null && request.get("keyword") != null
+            ? request.get("keyword").toString() : "";
+    return Result.success(dietIngredientService.searchFoods(keyword));
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+Run: `mvn clean compile`(`cfc-backend/` 下)
+Expected: BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java
+git commit -m "feat(diet): 换一批seed生效 + 食材搜索接口 /api/diet/ingredients/search"
+```
+
+---
+
+## Task 2: 后端「生成食谱」真实实现
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java`
+
+**Interfaces:**
+- Consumes:
+  - `Map<String, String> request`(date / meal_type / selected_foods 为 `[{food_id, name}]` JSON 字符串)
+  - `BeijingNutritionService.generateIngredientList(familyId, date)`(降级食材源)
+  - `MealConfigMapper`(查共餐参与者,参考 `BeijingNutritionService` 第 564-603 行逻辑)
+  - `FamilyMemberMapper`(无共餐配置时降级为全部成员,参考第 590-603 行)
+  - `AiGateway.generateMenu(String selectedFoodsJson, String participantsJson, String date)` → `menu_json` 字符串(LangGraph 契约见 Global Constraints)
+  - `com.fasterxml.jackson.databind.ObjectMapper`
+- Produces: `Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request)` 返回 `{id, menu, nutritionSummary, message}`,且落库 `diet_recommendations`(menu_json 已归一化、nutrition_summary 已聚合、participant_member_ids 已写入)
+
+- [ ] **步骤 1:重写 `generateRecommendation` 核心逻辑**
+
+`DietRecommendationService.java` 第 56-92 行替换为(保持方法签名):
+
+```java
+public Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request) {
+    String dateStr = request.get("date");
+    String mealType = request.get("meal_type");
+    LocalDate date = LocalDate.parse(dateStr);
+
+    // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
+    List<Map<String, Object>> selectedFoods = new ArrayList<>();
+    String selectedFoodsJson = request.get("selected_foods");
+    if (selectedFoodsJson != null && !selectedFoodsJson.isEmpty()) {
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode arr = om.readTree(selectedFoodsJson);
+            if (arr.isArray()) {
+                for (com.fasterxml.jackson.databind.JsonNode node : arr) {
+                    Map<String, Object> item = new HashMap<>();
+                    item.put("food_id", node.has("food_id") ? node.get("food_id").asLong() : null);
+                    item.put("name", node.has("name") ? node.get("name").asText() : "");
+                    selectedFoods.add(item);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 selected_foods 失败: {}", e.getMessage());
+        }
+    }
+
+    // 2. 查询共餐参与者(参考 BeijingNutritionService.generateIngredientList 的 meal_configs 逻辑)
+    List<Long> participantIds = queryParticipantIds(familyId, date);
+
+    // 3. 调用 AI 生成菜单(参与者数量至少 1)
+    String participantsJson = "[]";
+    if (!participantIds.isEmpty()) {
+        try {
+            participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
+        } catch (Exception ignore) {}
+    }
+    String menuJson = aiGateway.generateMenu(
+            selectedFoodsJson != null ? selectedFoodsJson : "[]",
+            participantsJson,
+            dateStr
+    );
+
+    // 4. AI 失败时降级:用规则推荐食材拼一个最小菜单,避免空壳
+    if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
+        menuJson = buildFallbackMenu(familyId, date, selectedFoods, participantIds.size());
+    }
+
+    // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
+    Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
+    String normalizedMenuJson = safeWrite(normalized);
+    Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
+
+    // 6. 保存推荐
+    DietRecommendation record = new DietRecommendation();
+    record.setFamilyId(familyId);
+    record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
+    record.setMealType(mealType != null ? mealType : "all");
+    record.setParticipantMemberIds(participantIds.toString());
+    record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
+    record.setNutritionSummary(safeWrite(nutritionSummary));
+    record.setStatus("pending");
+    record.setVersion(1);
+    record.setCreatedAt(new Date());
+    record.setUpdatedAt(new Date());
+    dietRecommendationMapper.insert(record);
+
+    Map<String, Object> result = new HashMap<>();
+    result.put("id", record.getId());
+    result.put("menu", record.getMenuJson());
+    result.put("nutritionSummary", record.getNutritionSummary());
+    result.put("message", "食谱推荐已生成");
+    return result;
+}
+```
+
+- [ ] **步骤 2:新增私有辅助方法 `queryParticipantIds` / `normalizeMenu` / `aggregateNutrition` / `buildFallbackMenu` / `safeWrite`**
+
+在 `DietRecommendationService.java` 类内新增(需要 `@Resource MealConfigMapper mealConfigMapper; @Resource FamilyMemberMapper familyMemberMapper;`,Bean Name 冲突检查:`DietRecommendationService` 内注入与 `BeijingNutritionService` 相同 mapper 类不冲突,因为是不同实例注入):
+
+```java
+private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
+    boolean weekend = date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY
+            || date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+    String dateType = weekend ? "weekend" : "weekday";
+    java.util.Set<Long> ids = new java.util.HashSet<>();
+    for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.MealConfig> w =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        w.eq(com.etotem.cfc.entity.MealConfig::getFamilyId, familyId)
+         .eq(com.etotem.cfc.entity.MealConfig::getConfigDateType, dateType)
+         .eq(com.etotem.cfc.entity.MealConfig::getMealType, mealType);
+        com.etotem.cfc.entity.MealConfig config = mealConfigMapper.selectOne(w);
+        if (config != null && config.getParticipantMemberIds() != null) {
+            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) ids.add(node.asLong());
+                }
+            } catch (Exception ignore) {}
+        }
+    }
+    if (ids.isEmpty()) {
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.FamilyMember> w =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        w.eq(com.etotem.cfc.entity.FamilyMember::getFamilyId, familyId);
+        List<com.etotem.cfc.entity.FamilyMember> members = familyMemberMapper.selectList(w);
+        for (com.etotem.cfc.entity.FamilyMember m : members) ids.add(m.getId());
+    }
+    return new ArrayList<>(ids);
+}
+
+private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
+    // LangGraph: {meals:[{type,name,dishes:[{name,ingredients:[{name,grams}],cooking_method,nutrition:{calories,...},notes}]}]}
+    // 前端期望: {meals:[{type,name,participants,dishes:[{name,calories,ingredients:[{name,amount}],method}]}]}
+    Map<String, Object> result = new LinkedHashMap<>();
+    List<Map<String, Object>> meals = new ArrayList<>();
+    result.put("meals", meals);
+    if (menuJson == null || menuJson.trim().isEmpty()) return result;
+    try {
+        com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+        com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
+        com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
+        if (mealsNode != null && mealsNode.isArray()) {
+            for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
+                Map<String, Object> meal = new LinkedHashMap<>();
+                meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
+                meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
+                meal.put("participants", participantCount > 0 ? participantCount : 1);
+                List<Map<String, Object>> dishes = new ArrayList<>();
+                com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
+                if (dishesNode != null && dishesNode.isArray()) {
+                    for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
+                        Map<String, Object> dish = new LinkedHashMap<>();
+                        dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
+                        com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
+                        if (nutrition != null) {
+                            dish.put("calories", nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
+                        } else {
+                            dish.put("calories", 0);
+                        }
+                        List<Map<String, Object>> ingredients = new ArrayList<>();
+                        com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
+                        if (ingNode != null && ingNode.isArray()) {
+                            for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
+                                Map<String, Object> item = new LinkedHashMap<>();
+                                item.put("name", ing.has("name") ? ing.get("name").asText() : "");
+                                item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
+                                ingredients.add(item);
+                            }
+                        }
+                        dish.put("ingredients", ingredients);
+                        dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
+                        dishes.add(dish);
+                    }
+                }
+                meal.put("dishes", dishes);
+                meals.add(meal);
+            }
+        }
+    } catch (Exception e) {
+        log.warn("归一化菜单失败: {}", e.getMessage());
+    }
+    return result;
+}
+
+private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
+    Map<String, Object> summary = new LinkedHashMap<>();
+    int calories = 0, protein = 0, carbs = 0, fat = 0;
+    @SuppressWarnings("unchecked")
+    List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
+    if (meals != null) {
+        for (Map<String, Object> meal : meals) {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
+            if (dishes == null) continue;
+            for (Map<String, Object> dish : dishes) {
+                Object cal = dish.get("calories");
+                if (cal instanceof Number) calories += ((Number) cal).intValue();
+            }
+        }
+    }
+    summary.put("calories", calories);
+    summary.put("protein", protein);
+    summary.put("carbs", carbs);
+    summary.put("fat", fat);
+    return summary;
+}
+
+private String buildFallbackMenu(Long familyId, LocalDate date, List<Map<String, Object>> selectedFoods, int participantCount) {
+    // AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单
+    List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
+            beijingNutritionService.generateIngredientList(familyId, date);
+    String[] mealDefs = {"breakfast", "lunch", "dinner"};
+    String[] mealNames = {"早餐", "午餐", "晚餐"};
+    Map<String, Object> root = new LinkedHashMap<>();
+    List<Map<String, Object>> meals = new ArrayList<>();
+    for (int i = 0; i < mealDefs.length; i++) {
+        Map<String, Object> meal = new LinkedHashMap<>();
+        meal.put("type", mealDefs[i]);
+        meal.put("name", mealNames[i]);
+        meal.put("participants", participantCount > 0 ? participantCount : 1);
+        List<Map<String, Object>> dishes = new ArrayList<>();
+        Map<String, Object> dish = new LinkedHashMap<>();
+        dish.put("name", "营养餐");
+        dish.put("calories", 0);
+        List<Map<String, Object>> dishIngredients = new ArrayList<>();
+        for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", ingredients.get(j).getName());
+            item.put("amount", 100);
+            dishIngredients.add(item);
+        }
+        dish.put("ingredients", dishIngredients);
+        dish.put("method", "建议以清淡为主");
+        dishes.add(dish);
+        meal.put("dishes", dishes);
+        meals.add(meal);
+    }
+    root.put("meals", meals);
+    return safeWrite(root);
+}
+
+private String safeWrite(Object obj) {
+    try {
+        return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+    } catch (Exception e) {
+        return "{}";
+    }
+}
+```
+
+> 说明:`aggregateNutrition` 目前只聚合 calories(LangGraph 未返回 protein/carbs/fat 结构一致字段时置 0);若 `nutrition` 含 protein/carbs/fat 字段可在后续补充,本期保证 calories 与前端 4 宫格可正常渲染(其余为 0)。
+
+- [ ] **步骤 3:新增 mapper 注入**
+
+在 `DietRecommendationService` 类顶部 `@Resource` 区新增:
+
+```java
+@Resource
+private com.etotem.cfc.mapper.MealConfigMapper mealConfigMapper;
+
+@Resource
+private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+```
+
+> Bean Name 冲突检查:`MealConfigMapper` / `FamilyMemberMapper` 在多个 Service 中已注入(如 `BeijingNutritionService`、`DietMealConfigService`),Spring 单例按类型注入,无命名冲突。
+
+- [ ] **步骤 4:编译验证**
+
+Run: `mvn clean compile`(`cfc-backend/` 下)
+Expected: BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
+git commit -m "feat(diet): 生成食谱使用所选食材+共餐参与者,归一化LangGraph菜单并聚合营养"
+```
+
+---
+
+## Task 3: 前端「生成食谱」+「换一批」+ 弹窗搜索(index.vue + api.js)
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`(新增 `searchDietFoods`)
+- Modify: `cfc-frontend/pages/diet/index.vue`
+
+**Interfaces:**
+- Consumes: `searchDietFoods(keyword)` → `POST /api/diet/ingredients/search` 返回 `{foods:[{id,name,category}]}`;`generateDietRecommendation` 返回 `{code,message,data:{id,...}}`
+- Produces: 修复后的 `index.vue`(generateRecipe 成功跳转、弹窗可搜索、按钮禁用态正确)
+
+- [ ] **步骤 1:`api.js` 新增搜索接口封装**
+
+在 `api.js` 现有 `getDietIngredients` 附近(第 2493-2494 行)新增:
+
+```js
+export const searchDietFoods = (keyword) => request('/api/diet/ingredients/search', 'POST', { keyword })
+```
+
+- [ ] **步骤 2:`index.vue` `generateRecipe` 取值修复**
+
+第 271-286 行,`if (res && res.id)` 改为:
+
+```js
+generateRecipe: function() {
+  var self = this
+  var foodIds = this.ingredients.map(function(item) { return { food_id: item.foodId, name: item.name } })
+  generateDietRecommendation({
+    date: self.formatDate(new Date()),
+    meal_type: 'all',
+    selected_foods: JSON.stringify(foodIds)
+  }).then(function(res) {
+    if (res && res.data && res.data.id) {
+      uni.showToast({ title: '食谱生成成功', icon: 'success' })
+      setTimeout(function() {
+        self.navTo('/pages/diet/recommendation')
+      }, 1000)
+    } else {
+      uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
+    }
+  })
+}
+```
+
+- [ ] **步骤 3:`index.vue` `selectedIngredients` 同步**
+
+`loadIngredients`(第 219-244 行)和 `refreshIngredients`(第 245-256 行)成功分支里,在 `self.ingredients = res.data.ingredients` 后追加:
+
+```js
+self.selectedIngredients = self.ingredients
+```
+
+同时 `removeIngredient` 成功回调里 `self.loadIngredients()` 已会重新同步;`addFood` 成功回调 `self.loadIngredients()` 同理。
+
+- [ ] **步骤 4:`index.vue` `searchFood` 实现 + `addFood` 修复**
+
+第 332-345 行替换为:
+
+```js
+searchFood: function() {
+  var self = this
+  var kw = (this.searchQuery || '').trim()
+  if (!kw) {
+    this.searchResults = []
+    return
+  }
+  searchDietFoods(kw).then(function(res) {
+    if (res && res.code === 200 && res.data && res.data.foods) {
+      self.searchResults = res.data.foods
+    }
+  })
+},
+addFood: function(food) {
+  var self = this
+  addDietIngredient({ food_id: food.id, name: food.name }).then(function(res) {
+    self.closePicker()
+    self.loadIngredients()
+  })
+}
+```
+
+> `addFood` 现有逻辑已正确(用 `food.id`),若未改动则保持。弹窗模板 `@tap="addFood(food)"` 已存在,`food.id`/`food.name` 与搜索返回字段一致,无需改模板。
+
+- [ ] **步骤 5:`index.vue` import 增加 `searchDietFoods`**
+
+第 161 行 import 行追加 `searchDietFoods`:
+
+```js
+import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
+```
+
+- [ ] **步骤 6:前端校验**
+
+Run(`cfc-frontend/` 下):
+```bash
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync('pages/diet/index.vue','utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+"
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue
+```
+Expected: `script OK` + grep 无输出
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/diet/index.vue
+git commit -m "feat(diet): 首页生成食谱取值修复+弹窗搜索实现+selectedIngredients同步"
+```
+
+---
+
+## Task 4: 前端「食谱推荐页」取值修复(recommendation.vue)
+
+**Files:**
+- Modify: `cfc-frontend/pages/diet/recommendation.vue`
+
+**Interfaces:**
+- Consumes: `getDietRecommendation` / `generateDietRecommendation` 返回 `{code,message,data:{id,menu,nutritionSummary,status}}`;后端已归一化的菜单结构
+- Produces: 修复后的 `recommendation.vue`(能加载今日推荐、空态生成可跳转、营养汇总/状态徽章正常)
+
+- [ ] **步骤 1:`loadRecommendation` 用 `res.data.*`**
+
+第 137-147 行替换为:
+
+```js
+loadRecommendation: function() {
+  var self = this
+  var today = self.formatDate(new Date())
+  getDietRecommendation({ date: today }).then(function(res) {
+    var d = res && res.data
+    if (d && d.id) {
+      self.recommendation = d
+      self.parseMenu(d.menu)
+      self.summary = d.nutritionSummary ? (typeof d.nutritionSummary === 'string' ? JSON.parse(d.nutritionSummary) : d.nutritionSummary) : {}
+    } else {
+      self.recommendation = null
+      self.meals = []
+      self.summary = {}
+    }
+  })
+}
+```
+
+- [ ] **步骤 2:`generateRecipe` 用 `res.data.id` + 传 `selected_foods`(若有)**
+
+第 175-191 行替换为:
+
+```js
+generateRecipe: function() {
+  var self = this
+  self.loading = true
+  generateDietRecommendation({
+    date: self.formatDate(new Date()),
+    meal_type: 'all'
+  }).then(function(res) {
+    self.loading = false
+    if (res && res.data && res.data.id) {
+      uni.showToast({ title: '食谱生成成功', icon: 'success' })
+      self.loadRecommendation()
+    } else {
+      uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
+    }
+  }).catch(function() {
+    self.loading = false
+    uni.showToast({ title: '生成失败', icon: 'none' })
+  })
+}
+```
+
+- [ ] **步骤 3:`parseMenu` 兼容后端归一化结构(含旧结构兜底)**
+
+第 148-174 行,`parseMenu` 内 `ing.amount` 已与后端归一化输出一致;为兼容后端可能存的旧数据,在 ingredient 解析处加兜底:
+
+```js
+parseMenu: function(menuJson) {
+  try {
+    var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
+    this.meals = []
+    var mealMap = {
+      'breakfast': { name: '早餐', icon: '🌅' },
+      'lunch': { name: '午餐', icon: '☀️' },
+      'dinner': { name: '晚餐', icon: '🌙' }
+    }
+    if (menu.meals) {
+      var self = this
+      menu.meals.forEach(function(meal) {
+        var config = mealMap[meal.type] || { name: meal.name || meal.type, icon: '🍽️' }
+        var dishes = (meal.dishes || []).map(function(dish) {
+          var ing = (dish.ingredients || []).map(function(item) {
+            return { name: item.name, amount: item.amount || item.grams || 0 }
+          })
+          return {
+            name: dish.name,
+            calories: dish.calories || (dish.nutrition && dish.nutrition.calories) || 0,
+            ingredients: ing,
+            method: dish.method || dish.cooking_method || ''
+          }
+        })
+        self.meals.push({
+          name: config.name,
+          icon: config.icon,
+          type: meal.type,
+          participants: meal.participants || 1,
+          dishes: dishes
+        })
+      })
+    }
+  } catch (e) {
+    console.error('解析菜单失败', e)
+  }
+}
+```
+
+> 说明:模板中 `dish.calories`/`ing.amount`/`dish.method` 字段名保持不变(后端归一化已对齐),此步骤仅加旧结构兜底 + 支持 `nutritionSummary` 为 JSON 字符串的情况。
+
+- [ ] **步骤 4:前端校验**
+
+Run(`cfc-frontend/` 下):
+```bash
+node -e "
+const fs=require('fs');
+const s=fs.readFileSync('pages/diet/recommendation.vue','utf8');
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
+console.log('script OK');
+"
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/recommendation.vue
+```
+Expected: `script OK` + grep 无输出
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages/diet/recommendation.vue
+git commit -m "fix(diet): 食谱推荐页 res.data 取值修复+菜单解析兼容"
+```
+
+---
+
+## Task 5: API 文档同步 + 全量复核
+
+**Files:**
+- Modify: `docs/superpowers/api/API_REFERENCE.md`
+
+- [ ] **步骤 1:API_REFERENCE 新增搜索接口记录**
+
+在 `docs/superpowers/api/API_REFERENCE.md` 第 353 行附近的 `### 4.11 饮食推荐(/api/diet/*)` 表格中追加:
+
+```markdown
+| `POST /api/diet/ingredients/search` | 搜索食材(keyword) |
+```
+
+- [ ] **步骤 2:全量编译 + 前端校验**
+
+Run:
+```bash
+cd cfc-backend && mvn clean compile
+cd cfc-frontend && node -e "..." pages/diet/index.vue pages/diet/recommendation.vue
+```
+Expected: BUILD SUCCESS + script OK × 2 + grep 无输出
+
+- [ ] **步骤 3:git 复核 staged 范围**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git diff --cached --stat
+```
+Expected: 仅包含目标文件
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git commit -m "docs(diet): API_REFERENCE 记录 /api/diet/ingredients/search"
+```
+
+---
+
+## 自检记录
+
+- [ ] **规格覆盖度**:spec §4.2(食材推荐流程:换一批/添加/生成)→ Task 1+3;§4.2 confirm→generate(食材确认+生成)→ Task 2;§6.1 接口 → Task 1+2;§7.3 首页布局按钮 → Task 3+4
+- [ ] **占位符扫描**:无 TODO/待定;每个代码步骤含实际代码
+- [ ] **类型一致性**:`res.data.id` / `res.data.menu` / `res.data.nutritionSummary` 前后端一致;后端 `normalizeMenu` 输出 `dish.calories/ingredients[].amount/method` 与前端 `parseMenu` 期望一致
+- [ ] **LangGraph 不改**:Python `/api/v1/menu/generate` 已实现,仅 Java 端消费其输出