Bladeren bron

feat(diet): 食材推荐-今日食谱增量变更(菜数硬保证/删除菜品/翻页修复/家庭总览聚合口径)

- DietRecommendationService 重写:generateMenu 菜单归一化 normalizeMenu()(R-05 菜数硬保证落 Java)、删除菜品复用 POST /api/diet/recommendation/update(规避 status=adjusted 陷阱)、重新生成链路
- recommendation.vue:成员切换条联动、菜品卡片操作区、删除后空/加载/失败态、翻页 scroll-view 高度修复(下滑翻页失效 BugFix)
- 新增 DietRecommendationServiceTest / QaBoundaryVerificationTest / recommendation.viewKeyAndDelete / pagination 契约测试
- 家庭食材总览分段聚合列(迁移465 基线确认 + FamilyFoodOverviewService 口径)
- cfc-langgraph meal.py:菜数要求入口 + prompt 模板软引导;seed_system_prompts.sql 同步
- 登记 API_REFERENCE.md;新增 2026-11-10 食材推荐食谱需求变更 PRD 与技术方案
asus 2 dagen geleden
bovenliggende
commit
181e830be
25 gewijzigde bestanden met toevoegingen van 3641 en 217 verwijderingen
  1. 91 2
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java
  3. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 15 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java
  5. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietRecommendation.java
  6. 11 3
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyFoodOverview.java
  7. 15 1
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  8. 550 120
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  9. 65 13
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyFoodOverviewService.java
  10. 10 6
      cfc-backend/src/main/resources/schema.sql
  11. 191 12
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java
  12. 536 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/DietRecommendationServiceTest.java
  13. 526 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/QaBoundaryVerificationTest.java
  14. 3 1
      cfc-frontend/__tests__/diet/recommendation.healthAdjustUi.test.js
  15. 291 0
      cfc-frontend/__tests__/pages/diet/recommendation.pagination.test.js
  16. 3 1
      cfc-frontend/__tests__/pages/diet/recommendation.participantSelect.test.js
  17. 3 1
      cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js
  18. 494 0
      cfc-frontend/__tests__/pages/diet/recommendation.viewKeyAndDelete.test.js
  19. 287 39
      cfc-frontend/pages/diet/recommendation.vue
  20. 14 2
      cfc-langgraph/app/api/meal.py
  21. 3 0
      cfc-langgraph/app/models/meal.py
  22. 15 6
      docs/superpowers/api/API_REFERENCE.md
  23. 67 0
      docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md
  24. 439 0
      docs/需求分析/2026-11-10-食材推荐食谱需求变更.md
  25. 6 4
      seed_system_prompts.sql

+ 91 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3491,6 +3491,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移464: 创建 P3 跨案例聚合承载表 attribution_case_strategies(T15 跨案例聚合,S-cfc-119 guhe.p3.lowSideAggregate)
         createAttributionCaseStrategyTable();
+
+        // 迁移465: family_food_overview 补家庭总览分段聚合列(食材推荐新口径)
+        // 规则:只要有成员指数 < 0 → 取全员最低值;全员 ≥ 0 → 取平均值(四舍五入取整)。
+        // 旧列 min_index_score 保留真实最小值,语义不变,接口向后兼容。
+        addFamilyFoodOverviewAggregateColumns();
+
+        // 迁移466: diet_recommendations 补视角隔离列 view_key + 索引(今日食谱随成员/视角切换)
+        addDietRecommendationViewKey();
     }
 
     /**
@@ -14825,8 +14833,9 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     }
 
     /**
-     * 迁移415: 创建 family_food_overview 表(家庭×食材 的持久化最小值总览,T28)。
-     * 家庭总览口径:家庭成员对该项食材最新推荐指数的最小值(最保守值),落库持久化。
+     * 迁移415: 创建 family_food_overview 表(家庭×食材 的持久化总览,T28)。
+     * 建表时口径为成员最小值;分段聚合展示列 overview_score / aggregate_rule 由迁移465 补齐
+     * (新口径:有成员&lt;0 取最低值,否则取平均值),min_index_score 保留真实最小值不变。
      * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
      */
     private void createFamilyFoodOverviewTable() {
@@ -14862,6 +14871,86 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移465: family_food_overview 补「家庭总览分段聚合」列(食材推荐口径变更)。
+     *
+     * <p>新口径:同一食材,只要有一名成员的推荐指数 &lt; 0,总览取全员最低值(风险优先);
+     * 全员 &gt;= 0 时取平均值(四舍五入取整,0 分计入平均)。旧列 min_index_score 仍保存
+     * 真实的成员最小值,供诊断与旧版客户端兼容,语义不变。</p>
+     *
+     * <p>幂等:ensureColumn 自动忽略「列已存在」;历史行的 overview_score 为 NULL,
+     * 由 FamilyFoodOverviewService 的指纹版本前缀强制重算、读取侧自愈补齐。</p>
+     */
+    private void addFamilyFoodOverviewAggregateColumns() {
+        ensureColumn("family_food_overview", "overview_score",
+                "INT NULL COMMENT '家庭总览展示值: 有成员<0取最低值, 否则取平均值(四舍五入)'");
+        ensureColumn("family_food_overview", "aggregate_rule",
+                "VARCHAR(24) NULL COMMENT '本次聚合规则: MIN_NEGATIVE / AVG_POSITIVE'");
+    }
+
+    /**
+     * 迁移466: diet_recommendations 补视角隔离列 view_key(今日食谱随成员/家庭总览切换,K1)。
+     *
+     * <p>view_key 为不透明字符串:member:&lt;id&gt; / family / NULL=存量。幂等键与 /today 查询键
+     * 统一追加该列,后端只做 .eq 匹配、不解析语义。<b>存量行不回填</b>(保持 NULL,老客户端不传参
+     * 仍走原查询);升级当天各视角空态需重新生成一次,写入发布说明即可。</p>
+     *
+     * <p>幂等:ensureColumn 忽略「列已存在」;indexExists 兜底补普通索引(<b>不加唯一索引</b>——
+     * regenerate 历史同键 insert 过多条,唯一索引会让启动迁移失败);后续 DML 带 WHERE 条件可重复执行。</p>
+     *
+     * <p>M4:把 R-02 历史 status='adjusted' 行归位为 pending(与 CR-01 历史行自愈同风格)。
+     * M6:仅当线上 content/default_content 仍含旧种子「早/午/晚各至少1-2道菜」句时,才用
+     * REPLACE 幂等同步菜数要求,避免覆盖运营自定义模板;菜数正确性由 Java normalizeMenu 硬保证,
+     * 不依赖 prompt 生效(改完仍需 clear_cache('menu_generate') 或等 300s TTL)。</p>
+     */
+    private void addDietRecommendationViewKey() {
+        ensureColumn("diet_recommendations", "view_key",
+                "VARCHAR(128) NULL COMMENT '视角维度: member:<id> / family / NULL=存量'");
+        try {
+            if (!indexExists("diet_recommendations", "idx_family_date_view")) {
+                jdbcTemplate.execute("ALTER TABLE diet_recommendations "
+                        + "ADD INDEX idx_family_date_view (family_id, recommendation_date, view_key)");
+                log.info("迁移466: 已为 diet_recommendations 补建索引 idx_family_date_view");
+            }
+        } catch (Exception e) {
+            log.warn("迁移466: idx_family_date_view 索引检查/补建失败(可能已存在): {}", e.getMessage());
+        }
+        // M4: R-02 历史 status='adjusted' 行归位(幂等,不影响新数据)
+        try {
+            jdbcTemplate.update("UPDATE diet_recommendations SET status = 'pending' WHERE status = 'adjusted'");
+        } catch (Exception e) {
+            log.warn("迁移466: adjusted 状态归位失败(忽略): {}", e.getMessage());
+        }
+        // M6: 仅当仍含旧种子「要求」块时整块替换,避免覆盖运营自定义菜单生成模板;
+        // 替换结果与 seed_system_prompts.sql 的新文案逐字一致(含 dish.type 标注要求)。
+        String oldLine = "1. 早/午/晚各至少1-2道菜";
+        String oldRequirements = "要求:\n"
+                + "1. 早/午/晚各至少1-2道菜\n"
+                + "2. 食材用量按{participant_count}人份计算\n"
+                + "3. 营养均衡,考虑健康目标\n"
+                + "4. 只用提供的食材\n"
+                + "5. 只返回 JSON,不要其他文字";
+        String newRequirements = "要求:\n"
+                + "1. 午餐和晚餐按上方约束给出的菜数配菜(D(N)=4+max(0,N-3) 道菜 + 1 道汤,汤恒 1 且不计入菜数、只加菜不加汤),早餐维持 1-2 道\n"
+                + "2. 食材用量按{participant_count}人份计算\n"
+                + "3. 营养均衡,考虑健康目标\n"
+                + "4. 只用提供的食材\n"
+                + "5. 每道菜用 type 字段标注 dish(菜)或 soup(汤),汤恰好 1 道且排在最后\n"
+                + "6. 只返回 JSON,不要其他文字";
+        try {
+            jdbcTemplate.update(
+                    "UPDATE system_prompts SET content = REPLACE(content, ?, ?) "
+                            + "WHERE prompt_key = 'menu_generate' AND content LIKE ?",
+                    oldRequirements, newRequirements, "%" + oldLine + "%");
+            jdbcTemplate.update(
+                    "UPDATE system_prompts SET default_content = REPLACE(default_content, ?, ?) "
+                            + "WHERE prompt_key = 'menu_generate' AND default_content LIKE ?",
+                    oldRequirements, newRequirements, "%" + oldLine + "%");
+        } catch (Exception e) {
+            log.warn("迁移466: menu_generate 种子文案幂等更新失败(忽略): {}", e.getMessage());
+        }
+    }
+
     // ==================== 迁移418:F-4 分佣收口 ====================
 
     /**

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java

@@ -67,7 +67,8 @@ public class FoodRecommendController {
         for (Map.Entry<Long, List<FoodRecommendIndex>> entry : byMember.entrySet()) {
             result.put(String.valueOf(entry.getKey()), entry.getValue());
         }
-        // T28:家庭总览持久化最小值(与 /api/health/foods/by-family 口径一致)
+        // T28/迁移465:家庭总览分段聚合(有成员<0取最低值、全员>=0取平均值),
+        // 与 /api/health/foods/by-family 口径一致;overview 元素含 overviewScore + 兼容字段 minIndexScore
         result.put("overview", familyFoodOverviewService.getOverviewMaps(familyId));
         return Result.success(result);
     }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -2081,7 +2081,8 @@ public class HealthReportController {
         result.put("total", totalList);
         result.put("byMember", byMember);
         result.put("memberNames", memberNames);
-        // T28:家庭总览持久化最小值(family_food_overview),无记录时后端先 backfill 再返回
+        // T28/迁移465:家庭总览分段聚合(family_food_overview,有成员<0取最低值、全员>=0取平均值),
+        // 无记录或旧规则历史行(overview_score 为 NULL)时后端先按新口径 backfill 再返回
         result.put("overview", familyFoodOverviewService.getOverviewMaps(familyId));
         return Result.success(result);
     }

+ 15 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java

@@ -14,24 +14,35 @@ public class DietRecommendationController {
     @Resource
     private DietRecommendationService dietRecommendationService;
 
+    /**
+     * 今日食谱(K1):可选 view_key 参与视角隔离;不传则完全走原查询(老客户端 / 存量 NULL 行兼容)。
+     * date 必填校验 Result.error("date不能为空") 原样保留。
+     */
     @PostMapping("/today")
     public Result<Map> getTodayRecommendation(
             @RequestAttribute(value = "familyId", required = false) Long familyId,
-            @RequestBody(required = false) Map<String, String> request) {
+            @RequestBody(required = false) Map<String, Object> request) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }
-        String date = (request != null) ? request.get("date") : null;
+        Object dateObj = (request != null) ? request.get("date") : null;
+        String date = (dateObj == null) ? null : String.valueOf(dateObj);
         if (date == null || date.isEmpty()) {
             return Result.error("date不能为空");
         }
-        return Result.success(dietRecommendationService.getTodayRecommendation(familyId, date));
+        Object viewKeyObj = (request != null) ? request.get("view_key") : null;
+        String viewKey = (viewKeyObj == null) ? null : String.valueOf(viewKeyObj);
+        return Result.success(dietRecommendationService.getTodayRecommendation(familyId, date, viewKey));
     }
 
+    /**
+     * 生成食谱(K3):改 Map&lt;String,Object&gt; 接住 participant_member_ids 的 JSON 字符串与原生数组双形态,
+     * 以及可选 view_key;date / meal_type / selected_foods 由 Service 侧 asString 兜底。
+     */
     @PostMapping("/generate")
     public Result<Map> generateRecommendation(
             @RequestAttribute(value = "familyId", required = false) Long familyId,
-            @RequestBody Map<String, String> request) {
+            @RequestBody Map<String, Object> request) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietRecommendation.java

@@ -22,4 +22,6 @@ public class DietRecommendation implements Serializable {
     private Integer version;
     private Date createdAt;
     private Date updatedAt;
+    /** 视角隔离键(迁移466):member:&lt;id&gt; / family / NULL=存量;后端视为不透明字符串,只做 .eq 匹配。 */
+    private String viewKey;
 }

+ 11 - 3
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyFoodOverview.java

@@ -9,8 +9,10 @@ import java.io.Serializable;
 import java.util.Date;
 
 /**
- * 家庭×食材 持久化最小值总览(T28)。
- * 口径:家庭成员对该项食材"最新推荐指数"的最小值(最保守值),由
+ * 家庭×食材 持久化总览(T28 建表,迁移465 改分段聚合口径)。
+ * 口径:同一食材,只要有一名成员推荐指数 &lt; 0,overviewScore 取全员最低值(风险优先);
+ * 全员 &gt;= 0 时取平均值(四舍五入取整,0 分计入平均)。minIndexScore 始终保存真实成员最小值,
+ * 供诊断与旧版客户端兼容,语义不变。由
  * {@link com.etotem.cfc.service.FamilyFoodOverviewService#recalculate(Long)} 全量覆盖维护。
  * memberScores 与项目 JSON 列既有惯例一致(如 diet_recommendations.menuJson)以字符串落库,
  * 内容为 JSON 数组 [{"userId":1018,"nickname":"小宇宙","score":-18}]
@@ -34,9 +36,15 @@ public class FamilyFoodOverview implements Serializable {
     /** 分类快照 */
     private String category;
 
-    /** 家庭成员该项食材最新推荐指数的最小值 */
+    /** 家庭成员该项食材最新推荐指数的最小值(诊断兼容字段,语义不变,非展示值) */
     private Integer minIndexScore;
 
+    /** 家庭总览展示值:有成员 &lt; 0 取最低值,否则取平均值(四舍五入) */
+    private Integer overviewScore;
+
+    /** 本次聚合规则:MIN_NEGATIVE(负值优先取最低)/ AVG_POSITIVE(全员非负取平均) */
+    private String aggregateRule;
+
     /** 参与计算的成员数(有该食材记录的) */
     private Integer memberCount;
 

+ 15 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -580,9 +580,18 @@ public class AiGateway {
     }
 
     /**
-     * 生成菜单
+     * 生成菜单(兼容旧调用方:不传 dish_count 时 LangGraph 侧保持原有行为)。
      */
     public String generateMenu(String selectedFoodsJson, String participantsJson, String date) {
+        return generateMenu(selectedFoodsJson, participantsJson, date, null);
+    }
+
+    /**
+     * 生成菜单(CR-C / AC-23):请求体携带 dish_count(= D(N),仅午/晚餐标准菜数)。
+     * dish_count 可为 null(LangGraph MenuGenerateRequest 的 Optional[int] 默认值兼容)。
+     * 不加重试、不改熔断 / 超时逻辑;菜数正确性由 Java 侧 normalizeMenu 硬保证。
+     */
+    public String generateMenu(String selectedFoodsJson, String participantsJson, String date, Integer dishCount) {
         if (!enabled || isCircuitOpen()) return null;
 
         try {
@@ -594,6 +603,11 @@ public class AiGateway {
             body.put("selected_foods", sanitizedFoods);
             body.put("participants", sanitizedParticipants);
             body.put("date", date);
+            if (dishCount != null) {
+                body.put("dish_count", dishCount.intValue());
+            } else {
+                body.putNull("dish_count");
+            }
             
             HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
             String url = baseUrl + "/api/v1/menu/generate";

+ 550 - 120
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -1,24 +1,60 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.IngredientRecommendation;
 import com.etotem.cfc.entity.DietRecommendation;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.MealConfig;
 import com.etotem.cfc.mapper.DietRecommendationMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.util.ParamUtils;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.time.LocalDate;
-import java.util.*;
-import com.etotem.cfc.util.ParamUtils;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
+/**
+ * 今日食谱服务。
+ *
+ * <p>本轮增量(迁移466 / K1~K6):</p>
+ * <ul>
+ *   <li>K1 数据隔离:{@code view_key} 不透明列参与 {@code /today} 查询键与 {@code /generate} 幂等键。
+ *       不传 {@code view_key} 时完全保持原查询(老客户端 / 存量 NULL 行兼容),后端不解析该串语义。</li>
+ *   <li>K2 删除:{@link #updateRecommendation} 不再把 {@code status} 置为 {@code adjusted},
+ *       保持原值以留在 {@code /today} 白名单内;并对 {@code menu_json} 做合法性校验。</li>
+ *   <li>K3 共餐人口径:优先读前端 {@code participant_member_ids}(字符串 / 数组双形态),
+ *       {@code meal_config} 仅作兜底;落库为排序去重的紧凑 JSON。</li>
+ *   <li>K4 菜数硬保证:唯一可信来源是 {@link #normalizeMenu},仅 lunch/dinner 生效,
+ *       {@code D(N)=4+max(0,N-3)} 道菜 + 恒 1 道汤,靠 {@code dish.type=dish|soup} 区分,不靠菜名猜。</li>
+ *   <li>K5 重新生成:走 {@link #generateRecommendation} 全链路,覆盖同视角记录,不再 insert 空菜单。</li>
+ * </ul>
+ */
 @Slf4j
 @Service
 public class DietRecommendationService {
 
+    /** 共享 ObjectMapper:无状态,可安全复用,避免每个请求反复 new。 */
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    /** /today 状态白名单:保持原样,不因删除而扩大(不含 adjusted / skipped)。 */
+    private static final List<String> TODAY_STATUS_WHITELIST =
+            Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
+
     @Resource
     private DietRecommendationMapper dietRecommendationMapper;
 
@@ -34,16 +70,39 @@ public class DietRecommendationService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    /**
+     * 兼容老客户端 / 其它存量调用方:不传 {@code view_key},走原查询。
+     *
+     * @param familyId 家庭 ID(拦截器注入)
+     * @param dateStr  yyyy-MM-dd
+     * @return 今日食谱视图(无记录时返回空态 Map)
+     */
     public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr) {
+        return getTodayRecommendation(familyId, dateStr, null);
+    }
+
+    /**
+     * 今日食谱查询(K1 视角隔离)。
+     *
+     * @param familyId 家庭 ID
+     * @param dateStr  yyyy-MM-dd
+     * @param viewKey  可选视角键;仅当非空时才追加 {@code .eq(viewKey)} + 按 version/id 倒序取最新
+     * @return 今日食谱视图;查不到返回空态,不回退展示其它视角(AC-04)
+     */
+    public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr, String viewKey) {
         LocalDate date = LocalDate.parse(dateStr);
-        
+
         LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(DietRecommendation::getFamilyId, familyId)
                .eq(DietRecommendation::getRecommendationDate, date)
-               .in(DietRecommendation::getStatus, Arrays.asList("pending", "accepted", "completed"));
-        
+               .in(DietRecommendation::getStatus, TODAY_STATUS_WHITELIST);
+        if (hasText(viewKey)) {
+            wrapper.eq(DietRecommendation::getViewKey, viewKey)
+                   .orderByDesc(DietRecommendation::getVersion, DietRecommendation::getId);
+        }
+
         List<DietRecommendation> records = dietRecommendationMapper.selectList(wrapper);
-        
+
         Map<String, Object> result = new HashMap<>();
         if (!records.isEmpty()) {
             DietRecommendation record = records.get(0);
@@ -55,76 +114,110 @@ public class DietRecommendationService {
             result.put("status", record.getStatus());
             result.put("version", record.getVersion());
             result.put("participantMemberIds", record.getParticipantMemberIds());
+            result.put("viewKey", record.getViewKey());
         } else {
             result.put("id", null);
             result.put("date", dateStr);
             result.put("message", "暂无推荐,请生成");
         }
-        
         return result;
     }
 
-    public Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request) {
-        String dateStr = request.get("date");
-        String mealType = request.get("meal_type");
+    /**
+     * 生成 / 重新生成食谱(AI 正常 → AI 失败降级 → 菜数归一化,三条路径统一收口于此)。
+     *
+     * @param familyId 家庭 ID
+     * @param request  {@code date / meal_type / selected_foods / participant_member_ids / view_key}
+     * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
+     */
+    public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
+        String dateStr = asString(request.get("date"));
+        if (!hasText(dateStr)) {
+            dateStr = LocalDate.now().toString();
+        }
         LocalDate date = LocalDate.parse(dateStr);
+        String mealType = asString(request.get("meal_type"));
+        String viewKey = asString(request.get("view_key"));
+        boolean hasViewKey = hasText(viewKey);
 
-        // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
-        String selectedFoodsJson = request.get("selected_foods");
-        if (selectedFoodsJson == null || selectedFoodsJson.trim().isEmpty()) {
+        // 1. 解析用户所选食材(selected_foods 兼容非 String 形态,asString 兜底)
+        String selectedFoodsJson = asString(request.get("selected_foods"));
+        if (!hasText(selectedFoodsJson)) {
             selectedFoodsJson = "[]";
         }
 
-        // 2. 查询共餐参与者
-        List<Long> participantIds = queryParticipantIds(familyId, date);
+        // 2. 共餐人:优先前端载荷,meal_config 仅兜底(K3 / D6①)
+        List<Long> participantIds = resolveParticipantIds(request, familyId, date);
+        int participantCount = participantIds.isEmpty() ? 1 : participantIds.size();
+        String participantsJson = participantIds.isEmpty() ? "[]" : safeWrite(participantIds);
+        String participantIdsJson = safeWrite(participantIds);
 
-        // 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, participantsJson, dateStr);
+        // 3. 菜数规则(K4):D(N) 仅作为 AI 的软引导;硬保证在 normalizeMenu
+        int dishCount = DishCountRule.dishCount(participantCount);
+        String menuJson = aiGateway.generateMenu(selectedFoodsJson, participantsJson, dateStr, dishCount);
 
-        // 4. AI 失败时降级:用推荐食材拼一个最小菜单,避免空壳
-        if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
-            menuJson = buildFallbackMenu(familyId, date, participantIds.size());
+        // 4. AI 失败 / 熔断 / 超时 → 规则降级(同菜数规则造 D(N)+1 道)
+        List<String> ingredientNames = availableIngredientNames(familyId, date);
+        if (menuJson == null || menuJson.trim().isEmpty() || "{}".equals(menuJson.trim())) {
+            menuJson = buildFallbackMenu(familyId, date, participantCount, ingredientNames);
         }
 
-        // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
-        Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
+        // 5. 归一化(唯一硬保证点)+ 聚合营养
+        Map<String, Object> normalized = normalizeMenu(menuJson, participantCount, ingredientNames);
+        // CR-3 / AC-24·26·27:LangGraph 在 LLM 无 JSON / 异常时返回 HTTP 200 的 {"meals": []},
+        // 非法 JSON 也会被 normalizeMenu 的异常兜底吞掉,二者都产出空菜单;
+        // 步骤 4 只判 null / 空串 / "{}" 会漏掉这两种形态。
+        // 归一化后再校验一次结构可用性,不足则复用规则降级(绝不重新调用 aiGateway,禁止整份重试 AI)。
+        // 于是 AI 正常 / 熔断降级 / 空·非法输出 三条路径最终落库均为 D(N) 菜 + 1 汤;
+        // regenerate 复用本方法(L283),同样受保护。
+        if (!hasUsableLunchDinner(normalized)) {
+            normalized = normalizeMenu(
+                    buildFallbackMenu(familyId, date, participantCount, ingredientNames),
+                    participantCount, ingredientNames);
+        }
         String normalizedMenuJson = safeWrite(normalized);
         Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
 
-        // 6. 幂等保存:同日同 mealType 已存在则更新,否则新建
+        // 6. 幂等保存:family_id + date + meal_type + view_key(view_key 未传时不追加,等价旧行为)
+        Date dateValue = java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant());
         LambdaQueryWrapper<DietRecommendation> existWrapper = new LambdaQueryWrapper<>();
         existWrapper.eq(DietRecommendation::getFamilyId, familyId)
-                    .eq(DietRecommendation::getRecommendationDate, java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()))
+                    .eq(DietRecommendation::getRecommendationDate, dateValue)
                     .eq(DietRecommendation::getMealType, mealType != null ? mealType : "all");
+        if (hasViewKey) {
+            existWrapper.eq(DietRecommendation::getViewKey, viewKey);
+        }
         DietRecommendation existRecord = dietRecommendationMapper.selectOne(existWrapper);
         if (existRecord != null) {
             existRecord.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
             existRecord.setNutritionSummary(safeWrite(nutritionSummary));
-            existRecord.setParticipantMemberIds(participantIds.toString());
+            existRecord.setParticipantMemberIds(participantIdsJson);
+            // 只在传了 view_key 时写入,避免老客户端把已有视角键清成 NULL(AC-08 不串视角)
+            if (hasViewKey) {
+                existRecord.setViewKey(viewKey);
+            }
             existRecord.setStatus("pending");
             existRecord.setUpdatedAt(new Date());
             dietRecommendationMapper.updateById(existRecord);
+
             Map<String, Object> result = new HashMap<>();
             result.put("id", existRecord.getId());
             result.put("menu", existRecord.getMenuJson());
             result.put("nutritionSummary", existRecord.getNutritionSummary());
+            result.put("participantCount", participantCount);
+            result.put("dishCount", dishCount);
             result.put("message", "食谱推荐已更新");
             return result;
         }
 
         DietRecommendation record = new DietRecommendation();
         record.setFamilyId(familyId);
-        record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
+        record.setRecommendationDate(dateValue);
         record.setMealType(mealType != null ? mealType : "all");
-        record.setParticipantMemberIds(participantIds.toString());
+        record.setParticipantMemberIds(participantIdsJson);
         record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
         record.setNutritionSummary(safeWrite(nutritionSummary));
+        record.setViewKey(hasViewKey ? viewKey : null);
         record.setStatus("pending");
         record.setVersion(1);
         record.setCreatedAt(new Date());
@@ -135,66 +228,96 @@ public class DietRecommendationService {
         result.put("id", record.getId());
         result.put("menu", record.getMenuJson());
         result.put("nutritionSummary", record.getNutritionSummary());
+        result.put("participantCount", participantCount);
+        result.put("dishCount", dishCount);
         result.put("message", "食谱推荐已生成");
         return result;
     }
 
+    /**
+     * 删除菜品复用 {@code POST /api/diet/recommendation/update}:整份 {@code menu_json} 覆盖。
+     *
+     * <p><b>R-02 规避</b>:绝不 {@code setStatus("adjusted")}(白名单不含该值会让删除后整页回空态),
+     * 保持原 {@code status} 不变;删除与补菜完全解耦,本路径<b>永不</b>调用 {@code normalizeMenu}。</p>
+     *
+     * @throws IllegalArgumentException {@code menu_json} 缺失 / 非法 JSON / 缺 meals 数组 →
+     *                                  由 {@code GlobalExceptionHandler} 映射为 Result.error(400, "menu_json格式非法")
+     */
     public void updateRecommendation(Long familyId, Map<String, Object> request) {
         Long id = ParamUtils.getLong(request.get("id"));
-        String menuJson = request.get("menu_json").toString();
-        
+        Object menuJsonObj = request.get("menu_json");
+        String menuJson = menuJsonObj == null ? null : menuJsonObj.toString();
+        validateMenuJson(menuJson);
+
         LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(DietRecommendation::getId, id)
                .eq(DietRecommendation::getFamilyId, familyId);
-        
+
         DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
         if (record != null) {
             record.setMenuJson(menuJson);
-            record.setStatus("adjusted");
+            // status 保持原值(pending/accepted/completed),/today 白名单仍可查 → AC-18
             record.setUpdatedAt(new Date());
             dietRecommendationMapper.updateById(record);
         }
     }
 
+    /**
+     * 重新生成(K5 / R-03):按原记录的 view_key / 共餐人 / 日期走 generateRecommendation 全链路,
+     * 覆盖同视角记录,version = old + 1,status 回 pending;不再 insert 空菜单记录。
+     *
+     * @return {@code {id, version, message}} 字段与语义保持不变
+     */
     public Map<String, Object> regenerateRecommendation(Long familyId, Map<String, Object> request) {
         Long id = ParamUtils.getLong(request.get("id"));
-        
+
         LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(DietRecommendation::getId, id)
                .eq(DietRecommendation::getFamilyId, familyId);
-        
+
         DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
-        if (record != null) {
-            // 复制当前记录,version+1
-            DietRecommendation newRecord = new DietRecommendation();
-            newRecord.setFamilyId(familyId);
-            newRecord.setRecommendationDate(record.getRecommendationDate());
-            newRecord.setMealType(record.getMealType());
-            newRecord.setParticipantMemberIds(record.getParticipantMemberIds());
-            newRecord.setMenuJson("{}"); // 重新生成
-            newRecord.setStatus("pending");
-            newRecord.setVersion(record.getVersion() + 1);
-            newRecord.setCreatedAt(new Date());
-            newRecord.setUpdatedAt(new Date());
-            dietRecommendationMapper.insert(newRecord);
-            
-            Map<String, Object> result = new HashMap<>();
-            result.put("id", newRecord.getId());
-            result.put("version", newRecord.getVersion());
-            result.put("message", "已重新生成食谱,版本号: " + newRecord.getVersion());
-            return result;
+        if (record == null) {
+            return new HashMap<>();
         }
-        
-        return new HashMap<>();
+
+        int oldVersion = record.getVersion() == null ? 1 : record.getVersion();
+        String dateStr = formatRecordDate(record.getRecommendationDate());
+
+        Map<String, Object> genRequest = new HashMap<>();
+        genRequest.put("date", dateStr);
+        genRequest.put("meal_type", record.getMealType() != null ? record.getMealType() : "all");
+        genRequest.put("selected_foods", "[]");
+        // 原记录共餐人(字符串或紧凑 JSON)→ resolveParticipantIds 复用 parseMemberIds 解析
+        genRequest.put("participant_member_ids", record.getParticipantMemberIds());
+        genRequest.put("view_key", record.getViewKey());
+
+        Map<String, Object> generated = generateRecommendation(familyId, genRequest);
+
+        // generateRecommendation 已按幂等键覆盖同 view_key 记录(menu_json 非空、status=pending);
+        // 此处仅覆写版本号,避免保留历史版本行 / 空菜单。
+        DietRecommendation fresh = dietRecommendationMapper.selectOne(wrapper);
+        if (fresh != null) {
+            fresh.setVersion(oldVersion + 1);
+            fresh.setStatus("pending");
+            fresh.setUpdatedAt(new Date());
+            dietRecommendationMapper.updateById(fresh);
+        }
+
+        Object generatedId = generated.get("id");
+        Map<String, Object> result = new HashMap<>();
+        result.put("id", generatedId != null ? generatedId : id);
+        result.put("version", oldVersion + 1);
+        result.put("message", "已重新生成食谱,版本号: " + (oldVersion + 1));
+        return result;
     }
 
     public void completeRecommendation(Long familyId, Map<String, Object> request) {
         Long id = ParamUtils.getLong(request.get("id"));
-        
+
         LambdaQueryWrapper<DietRecommendation> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(DietRecommendation::getId, id)
                .eq(DietRecommendation::getFamilyId, familyId);
-        
+
         DietRecommendation record = dietRecommendationMapper.selectOne(wrapper);
         if (record != null) {
             record.setStatus("completed");
@@ -204,11 +327,113 @@ public class DietRecommendationService {
     }
 
     // ============================================================
-    // 内部辅助
+    // 内部辅助:view_key / 参数归一
     // ============================================================
 
+    /** 非空判定:null / 空串 / 全空白一律视为「未传」,走老客户端兼容路径。 */
+    private static boolean hasText(String value) {
+        return value != null && !value.trim().isEmpty();
+    }
+
+    /** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
+    private static String asString(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof String) {
+            return (String) value;
+        }
+        return String.valueOf(value);
+    }
+
     /**
-     * 查询共餐参与者
+     * participant_member_ids 双形态归一(K3):JSON 字符串 "[1,2]" 复用 parseMemberIds;
+     * 原生 List / 数组 / 单个数字 → Set&lt;Long&gt; 去重;其余形态返回空集合(回落兜底)。
+     */
+    private List<Long> asIds(Object value) {
+        Set<Long> ids = new HashSet<>();
+        if (value == null) {
+            return new ArrayList<>(ids);
+        }
+        if (value instanceof String) {
+            ids.addAll(parseMemberIds((String) value));
+        } else if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                Long parsed = toLong(item);
+                if (parsed != null) {
+                    ids.add(parsed);
+                }
+            }
+        } else if (value.getClass().isArray()) {
+            int len = java.lang.reflect.Array.getLength(value);
+            for (int i = 0; i < len; i++) {
+                Long parsed = toLong(java.lang.reflect.Array.get(value, i));
+                if (parsed != null) {
+                    ids.add(parsed);
+                }
+            }
+        } else {
+            Long parsed = toLong(value);
+            if (parsed != null) {
+                ids.add(parsed);
+            }
+        }
+        return new ArrayList<>(ids);
+    }
+
+    private static Long toLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        return ParamUtils.getLong(value);
+    }
+
+    /**
+     * 共餐人 N 口径唯一入口:优先前端 participant_member_ids,为空才回落 meal_config 兜底;
+     * 结果排序去重,落库为紧凑 JSON(F-15 的 "[1, 2]" 带空格问题一并消除)。
+     */
+    private List<Long> resolveParticipantIds(Map<String, Object> request, Long familyId, LocalDate date) {
+        List<Long> ids = asIds(request.get("participant_member_ids"));
+        if (ids.isEmpty()) {
+            ids = queryParticipantIds(familyId, date);
+        }
+        Collections.sort(ids);
+        return ids;
+    }
+
+    private static String formatRecordDate(Date date) {
+        if (date == null) {
+            return LocalDate.now().toString();
+        }
+        return new SimpleDateFormat("yyyy-MM-dd").format(date);
+    }
+
+    /** menu_json 合法性:必须是含 meals 数组的 JSON 对象,防止删除路径写坏数据(R-9)。 */
+    private static void validateMenuJson(String menuJson) {
+        if (!hasText(menuJson)) {
+            throw new IllegalArgumentException("menu_json格式非法");
+        }
+        try {
+            JsonNode root = OBJECT_MAPPER.readTree(menuJson);
+            if (root == null || !root.isObject() || !root.has("meals") || !root.get("meals").isArray()) {
+                throw new IllegalArgumentException("menu_json格式非法");
+            }
+        } catch (IllegalArgumentException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalArgumentException("menu_json格式非法");
+        }
+    }
+
+    // ============================================================
+    // 内部辅助:共餐人兜底 / 可用食材
+    // ============================================================
+
+    /**
+     * 查询共餐参与者(兜底,逻辑不动)
      * 优先级:当天临时配置(config_date=当天) > 工作日/周末默认配置(config_date=NULL) > 全部家庭成员
      */
     private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
@@ -217,7 +442,6 @@ public class DietRecommendationService {
         String dateType = weekend ? "weekend" : "weekday";
         Set<Long> ids = new HashSet<>();
 
-        // 优先查当天临时配置(config_date = 当天)
         for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
             LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
             w.eq(MealConfig::getFamilyId, familyId)
@@ -229,7 +453,6 @@ public class DietRecommendationService {
             }
         }
 
-        // 当天无临时配置时,降级到工作日/周末默认配置
         if (ids.isEmpty()) {
             for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
                 LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
@@ -244,67 +467,171 @@ public class DietRecommendationService {
             }
         }
 
-        // 仍无配置时,降级为全部家庭成员
         if (ids.isEmpty()) {
             LambdaQueryWrapper<FamilyMember> w = new LambdaQueryWrapper<>();
             w.eq(FamilyMember::getFamilyId, familyId);
             List<FamilyMember> members = familyMemberMapper.selectList(w);
-            for (FamilyMember m : members) ids.add(m.getId());
+            if (members != null) {
+                for (FamilyMember m : members) {
+                    ids.add(m.getId());
+                }
+            }
         }
         return new ArrayList<>(ids);
     }
 
     private Set<Long> parseMemberIds(String json) {
         Set<Long> ids = new HashSet<>();
-        if (json == null || json.isEmpty()) return ids;
+        if (json == null || json.isEmpty()) {
+            return ids;
+        }
         try {
-            com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
+            JsonNode nodes = OBJECT_MAPPER.readTree(json);
             if (nodes.isArray()) {
-                for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
+                for (JsonNode node : nodes) {
+                    ids.add(node.asLong());
+                }
             }
-        } catch (Exception ignore) {}
+        } catch (Exception ignore) {
+            // 非法 JSON 视为空集合,由 resolveParticipantIds 回落兜底
+        }
         return ids;
     }
 
-    /** 归一化 LangGraph 菜单 → 前端期望结构(dish.calories / ingredients[].amount / method) */
+    /** 读取北京营养服务的可用食材名,造补齐占位菜用;服务不可用时返回空列表(用通用占位名)。 */
+    private List<String> availableIngredientNames(Long familyId, LocalDate date) {
+        List<String> names = new ArrayList<>();
+        try {
+            List<IngredientRecommendation> ingredients =
+                    beijingNutritionService.generateIngredientList(familyId, date);
+            if (ingredients != null) {
+                for (IngredientRecommendation ing : ingredients) {
+                    if (ing != null && hasText(ing.getName()) && !names.contains(ing.getName())) {
+                        names.add(ing.getName());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("读取可用食材失败,补齐改用通用占位菜: {}", e.getMessage());
+        }
+        return names;
+    }
+
+    // ============================================================
+    // 内部辅助:菜数规则 / 归一化 / 降级菜单
+    // ============================================================
+
+    /**
+     * 归一化结果的可用性闸门(AC-24/26/27):至少存在 lunch 与 dinner 两餐,且各自带至少 1 道菜。
+     *
+     * <p>AI 返回 {@code {"meals": []}}、{@code {"meals":"x"}}、无 meals 字段、非法 JSON,
+     * 或只给了早餐时,{@code normalizeMenu} 会产出空 / 残缺菜单,这类结果不允许落库。
+     * 菜数是否足量由 {@code normalizeMenu} 的补齐 / 截断保证,此处只判断结构是否可用,
+     * 避免把「合法但菜数偏少」的 AI 输出误判为失败(那种情况应保留 AI 结果并由补齐逻辑处理)。</p>
+     *
+     * @param normalized normalizeMenu 的输出
+     * @return true 表示结构可用可直接落库;false 表示需走 buildFallbackMenu 规则降级
+     */
+    private static boolean hasUsableLunchDinner(Map<String, Object> normalized) {
+        if (normalized == null) {
+            return false;
+        }
+        Object mealsObj = normalized.get("meals");
+        if (!(mealsObj instanceof List)) {
+            return false;
+        }
+        boolean hasLunch = false;
+        boolean hasDinner = false;
+        for (Object item : (List<?>) mealsObj) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<?, ?> meal = (Map<?, ?>) item;
+            Object dishesObj = meal.get("dishes");
+            if (!(dishesObj instanceof List) || ((List<?>) dishesObj).isEmpty()) {
+                continue;
+            }
+            Object type = meal.get("type");
+            if ("lunch".equals(type)) {
+                hasLunch = true;
+            } else if ("dinner".equals(type)) {
+                hasDinner = true;
+            }
+        }
+        return hasLunch && hasDinner;
+    }
+
+    /**
+     * 兼容旧签名(类图约定):补齐时使用通用占位菜名。
+     *
+     * @param menuJson         AI 或降级菜单 JSON
+     * @param participantCount 共餐人数 N(<=0 兜底 1)
+     * @return 归一化后的菜单 Map(meals[].dishes[] 每道带 type)
+     */
     private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
+        return normalizeMenu(menuJson, participantCount, Collections.<String>emptyList());
+    }
+
+    /**
+     * 菜数硬保证唯一可信来源(K4 / AC-20~26)。
+     * 仅 lunch/dinner:按 dish.type 拆 mains(≠soup)/soups(=soup) → mains 不足补齐、超出截断到 D;
+     * soups 为 0 补 1 道、超过 1 截断到 1;输出 mains(D) + soups(1),菜在前汤在后。
+     * breakfast 原样透传(不改数量),仅补 type 便于前端识别。
+     */
+    private Map<String, Object> normalizeMenu(String menuJson, int participantCount, List<String> ingredientNames) {
         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;
+        if (menuJson == null || menuJson.trim().isEmpty()) {
+            return result;
+        }
+
+        int n = DishCountRule.normalizeN(participantCount);
+        List<String> names = ingredientNames == null ? Collections.<String>emptyList() : ingredientNames;
         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");
+            JsonNode root = OBJECT_MAPPER.readTree(menuJson);
+            JsonNode mealsNode = root.get("meals");
             if (mealsNode != null && mealsNode.isArray()) {
-                for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
+                for (JsonNode mealNode : mealsNode) {
+                    String mealType = mealNode.has("type") ? mealNode.get("type").asText() : "lunch";
                     Map<String, Object> meal = new LinkedHashMap<>();
-                    meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
+                    meal.put("type", mealType);
                     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");
+                    meal.put("participants", n);
+
+                    List<Map<String, Object>> mains = new ArrayList<>();
+                    List<Map<String, Object>> soups = new ArrayList<>();
+                    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");
-                            dish.put("calories", nutrition != null && nutrition.has("calories") ? nutrition.get("calories").asInt() : 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);
-                                }
+                        for (JsonNode dishNode : dishesNode) {
+                            Map<String, Object> dish = toDish(dishNode);
+                            if ("soup".equals(dish.get("type"))) {
+                                soups.add(dish);
+                            } else {
+                                mains.add(dish);
                             }
-                            dish.put("ingredients", ingredients);
-                            dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
-                            dishes.add(dish);
                         }
                     }
+
+                    List<Map<String, Object>> dishes = new ArrayList<>();
+                    if (DishCountRule.appliesTo(mealType)) {
+                        int expected = DishCountRule.dishCount(n);
+                        while (mains.size() < expected) {
+                            mains.add(placeholderDish(names, "dish", mains.size(), "营养配菜"));
+                        }
+                        if (mains.size() > expected) {
+                            mains = new ArrayList<>(mains.subList(0, expected));
+                        }
+                        if (soups.isEmpty()) {
+                            soups.add(placeholderDish(names, "soup", 0, "营养汤"));
+                        }
+                        if (soups.size() > DishCountRule.soupCount()) {
+                            soups = new ArrayList<>(soups.subList(0, DishCountRule.soupCount()));
+                        }
+                    }
+                    // 顺序约定:菜在前、汤在后(早餐亦保持原有相对顺序,仅分组)
+                    dishes.addAll(mains);
+                    dishes.addAll(soups);
                     meal.put("dishes", dishes);
                     meals.add(meal);
                 }
@@ -315,7 +642,73 @@ public class DietRecommendationService {
         return result;
     }
 
-    /** 聚合营养汇总(目前聚合热量,其余字段置 0,供前端 4 宫格渲染) */
+    /** 单道菜归一:兼容 AI 的 cooking_method/grams/nutrition 与前端落库的 method/amount/calories。 */
+    private static Map<String, Object> toDish(JsonNode dishNode) {
+        Map<String, Object> dish = new LinkedHashMap<>();
+        dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
+        dish.put("type", dishNode.has("type") ? dishNode.get("type").asText() : "dish");
+        JsonNode nutrition = dishNode.get("nutrition");
+        if (nutrition != null && nutrition.has("calories")) {
+            dish.put("calories", nutrition.get("calories").asInt());
+        } else if (dishNode.has("calories")) {
+            dish.put("calories", dishNode.get("calories").asInt());
+        } else {
+            dish.put("calories", 0);
+        }
+        List<Map<String, Object>> ingredients = new ArrayList<>();
+        JsonNode ingNode = dishNode.get("ingredients");
+        if (ingNode != null && ingNode.isArray()) {
+            for (JsonNode ing : ingNode) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("name", ing.has("name") ? ing.get("name").asText() : "");
+                if (ing.has("grams")) {
+                    item.put("amount", ing.get("grams").asInt());
+                } else if (ing.has("amount")) {
+                    item.put("amount", ing.get("amount").asInt());
+                } else {
+                    item.put("amount", 0);
+                }
+                ingredients.add(item);
+            }
+        }
+        dish.put("ingredients", ingredients);
+        if (dishNode.has("cooking_method")) {
+            dish.put("method", dishNode.get("cooking_method").asText());
+        } else if (dishNode.has("method")) {
+            dish.put("method", dishNode.get("method").asText());
+        } else {
+            dish.put("method", "");
+        }
+        return dish;
+    }
+
+    /** 占位菜/汤:结构与 toDish 输出一致(归一化后结构),calories=0,带可识别的 type。 */
+    private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
+        Map<String, Object> dish = new LinkedHashMap<>();
+        String label;
+        if ("soup".equals(type)) {
+            label = names.isEmpty() ? "营养汤" : names.get(0) + "汤";
+        } else if (names.isEmpty()) {
+            label = defaultName;
+        } else {
+            label = names.get(seq % names.size()) + "·" + defaultName;
+        }
+        dish.put("name", label);
+        dish.put("type", type);
+        dish.put("calories", 0);
+        List<Map<String, Object>> ingredients = new ArrayList<>();
+        if (!names.isEmpty()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", names.get(seq % names.size()));
+            item.put("amount", 100);
+            ingredients.add(item);
+        }
+        dish.put("ingredients", ingredients);
+        dish.put("method", "建议以清淡为主");
+        return dish;
+    }
+
+    /** 聚合营养汇总(仅回归:汤也计入热量;protein/carbs/fat 恒 0 不在本轮范围)。 */
     private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
         Map<String, Object> summary = new LinkedHashMap<>();
         int calories = 0;
@@ -325,10 +718,14 @@ public class DietRecommendationService {
             for (Map<String, Object> meal : meals) {
                 @SuppressWarnings("unchecked")
                 List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
-                if (dishes == null) continue;
+                if (dishes == null) {
+                    continue;
+                }
                 for (Map<String, Object> dish : dishes) {
                     Object cal = dish.get("calories");
-                    if (cal instanceof Number) calories += ((Number) cal).intValue();
+                    if (cal instanceof Number) {
+                        calories += ((Number) cal).intValue();
+                    }
                 }
             }
         }
@@ -339,10 +736,18 @@ public class DietRecommendationService {
         return summary;
     }
 
-    /** AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单 */
+    /** 兼容旧签名(类图约定):自行读取可用食材。 */
     private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
-        List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
-                beijingNutritionService.generateIngredientList(familyId, date);
+        return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));
+    }
+
+    /**
+     * AI 不可用时的规则降级(K4 / AC-24):lunch/dinner 造 D(N) 道菜 + 1 道汤(带 type),
+     * 早餐维持现状 1 道「营养餐」。输出仍经 normalizeMenu 归一化,保证三条路径同规则。
+     */
+    private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount, List<String> ingredientNames) {
+        int n = DishCountRule.normalizeN(participantCount);
+        List<String> names = ingredientNames == null ? Collections.<String>emptyList() : ingredientNames;
         String[] mealTypes = {"breakfast", "lunch", "dinner"};
         String[] mealNames = {"早餐", "午餐", "晚餐"};
         Map<String, Object> root = new LinkedHashMap<>();
@@ -351,21 +756,17 @@ public class DietRecommendationService {
             Map<String, Object> meal = new LinkedHashMap<>();
             meal.put("type", mealTypes[i]);
             meal.put("name", mealNames[i]);
-            meal.put("participants", participantCount > 0 ? participantCount : 1);
+            meal.put("participants", n);
             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);
+            if (DishCountRule.appliesTo(mealTypes[i])) {
+                int expected = DishCountRule.dishCount(n);
+                for (int k = 0; k < expected; k++) {
+                    dishes.add(placeholderDish(names, "dish", k, "营养配菜"));
+                }
+                dishes.add(placeholderDish(names, "soup", 0, "营养汤"));
+            } else {
+                dishes.add(placeholderDish(names, "dish", 0, "营养餐"));
             }
-            dish.put("ingredients", dishIngredients);
-            dish.put("method", "建议以清淡为主");
-            dishes.add(dish);
             meal.put("dishes", dishes);
             meals.add(meal);
         }
@@ -373,11 +774,40 @@ public class DietRecommendationService {
         return safeWrite(root);
     }
 
-    private String safeWrite(Object obj) {
+    private static String safeWrite(Object obj) {
         try {
-            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+            return OBJECT_MAPPER.writeValueAsString(obj);
         } catch (Exception e) {
             return "{}";
         }
     }
+
+    /**
+     * 菜数规则唯一常量源(CR-C / D3·D4)。
+     * 仅 lunch/dinner 适用;N<=0 兜底 1;D(N)=4+max(0,N-3);汤恒 1 且不计入菜数。
+     */
+    private static final class DishCountRule {
+        private DishCountRule() {
+        }
+
+        static int normalizeN(int n) {
+            return n <= 0 ? 1 : n;
+        }
+
+        static int dishCount(int n) {
+            return 4 + Math.max(0, normalizeN(n) - 3);
+        }
+
+        static int soupCount() {
+            return 1;
+        }
+
+        static int total(int n) {
+            return dishCount(n) + soupCount();
+        }
+
+        static boolean appliesTo(String mealType) {
+            return "lunch".equals(mealType) || "dinner".equals(mealType);
+        }
+    }
 }

+ 65 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyFoodOverviewService.java

@@ -27,11 +27,18 @@ import java.util.Map;
 import java.util.Set;
 
 /**
- * 家庭×食材 持久化最小值总览服务(T28)。
- * 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 user_id+food_id 的 index_score
- * (recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
- * "最小值" = 该家庭可登录成员(family_members.user_id 非空且非回收箱)中对该食材有指数记录的成员取 MIN。
- * 无记录的成员不计入 min;全员无记录则该食材不出现在总览。
+ * 家庭×食材 持久化总览服务(T28 建表,迁移465 改分段聚合口径)。
+ * 某成员某项食材的"最新值" = food_recommend_idx 中该 user_id+food_id 的 index_score
+ * (recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max)。
+ * 参与成员 = 家庭可登录成员(family_members.user_id 非空且非回收箱)中对该食材有指数记录的人;
+ * 无记录成员不计入,全员无记录则该食材不出现在总览。
+ *
+ * <p>家庭总览展示口径(overviewScore):
+ * <ul>
+ *   <li>只要有一名成员指数 &lt; 0 → 取全员最低值(MIN_NEGATIVE,风险优先,一人戒备全家提示)</li>
+ *   <li>全员指数 &gt;= 0 → 取平均值(AVG_POSITIVE,四舍五入取整,0 分计入平均)</li>
+ * </ul>
+ * minIndexScore 始终保存真实成员最小值,语义不变,供诊断与旧版客户端兼容。
  *
  * 重要:本服务不标 @Transactional(与 NormalizeIndicatorPipeline 一致)——由
  * FoodRecommendService.recalculateForUser 末尾触发时,若本服务抛异常被调用方 catch 住,
@@ -41,6 +48,15 @@ import java.util.Set;
 @Service
 public class FamilyFoodOverviewService {
 
+    /** 聚合规则版本:变更时强制历史行指纹失配、全量重算一次(幂等仍保持) */
+    private static final String RULE_VERSION = "v2-min-negative-avg-positive";
+
+    /** 聚合规则标识:存在负值,取全员最低值 */
+    private static final String RULE_MIN_NEGATIVE = "MIN_NEGATIVE";
+
+    /** 聚合规则标识:全员非负,取平均值 */
+    private static final String RULE_AVG_POSITIVE = "AVG_POSITIVE";
+
     @Resource
     private FamilyFoodOverviewMapper familyFoodOverviewMapper;
 
@@ -66,12 +82,22 @@ public class FamilyFoodOverviewService {
     }
 
     /**
-     * 读取持久化总览(原始实体列表);该 family 无记录时先 backfill 再返回(AC-11 历史家庭不回空)。
+     * 读取持久化总览(原始实体列表);无记录或存在迁移465 之前的历史行(overviewScore 为 NULL)时
+     * 先重算 backfill 再返回(AC-11 历史家庭不回空,且旧规则数据自动升级到新口径)。
      */
     public List<FamilyFoodOverview> getOverview(Long familyId) {
         if (familyId == null) return new ArrayList<>();
         List<FamilyFoodOverview> list = selectByFamily(familyId);
-        if (list == null || list.isEmpty()) {
+        boolean needRecalculate = list == null || list.isEmpty();
+        if (!needRecalculate) {
+            for (FamilyFoodOverview row : list) {
+                if (row == null || row.getOverviewScore() == null) {
+                    needRecalculate = true;
+                    break;
+                }
+            }
+        }
+        if (needRecalculate) {
             doRecalculate(familyId);
             list = selectByFamily(familyId);
         }
@@ -80,8 +106,10 @@ public class FamilyFoodOverviewService {
 
     /**
      * 读取持久化总览并转换为接口返回结构(overview 元素),与后端两处 by-family 接口口径一致。
-     * 元素结构:{foodId, foodName, category, minIndexScore, memberCount,
-     *           memberScores:[{userId,nickname,score}], calculatedAt}
+     * 元素结构:{foodId, foodName, category, overviewScore, aggregateRule, minIndexScore,
+     *           memberCount, memberScores:[{userId,nickname,score}], calculatedAt}
+     * minIndexScore 保留旧字段(真实成员最小值)向后兼容;overviewScore 为新版展示值。
+     * 极端情况下历史行尚未重算完成时,overviewScore 回退为 minIndexScore(旧口径),不会返回 null。
      */
     public List<Map<String, Object>> getOverviewMaps(Long familyId) {
         List<FamilyFoodOverview> list = getOverview(familyId);
@@ -92,6 +120,10 @@ public class FamilyFoodOverviewService {
             item.put("foodId", fp.getFoodId());
             item.put("foodName", fp.getFoodName());
             item.put("category", fp.getCategory());
+            item.put("overviewScore", fp.getOverviewScore() != null
+                    ? fp.getOverviewScore() : fp.getMinIndexScore());
+            item.put("aggregateRule", fp.getAggregateRule() != null
+                    ? fp.getAggregateRule() : RULE_MIN_NEGATIVE);
             item.put("minIndexScore", fp.getMinIndexScore());
             item.put("memberCount", fp.getMemberCount());
             item.put("memberScores", parseMemberScores(fp.getMemberScores()));
@@ -105,7 +137,7 @@ public class FamilyFoodOverviewService {
         return familyFoodOverviewMapper.selectList(
                 new LambdaQueryWrapper<FamilyFoodOverview>()
                         .eq(FamilyFoodOverview::getFamilyId, familyId)
-                        .orderByAsc(FamilyFoodOverview::getMinIndexScore)
+                        .orderByAsc(FamilyFoodOverview::getOverviewScore)
                         .orderByAsc(FamilyFoodOverview::getFoodId));
     }
 
@@ -194,13 +226,15 @@ public class FamilyFoodOverviewService {
             }
         }
 
-        // 4. 逐 food_id 计算 min / member_count / member_scores / fingerprint
+        // 4. 逐 food_id 计算展示值 / min / member_count / member_scores / fingerprint
         Set<Long> currentFoodIds = new HashSet<>();
         for (Map.Entry<Long, List<FoodRecommendIndex>> e : byFood.entrySet()) {
             Long foodId = e.getKey();
             List<FoodRecommendIndex> rows = e.getValue();
 
             int min = Integer.MAX_VALUE;
+            long sum = 0L;
+            boolean hasNegative = false;
             List<Map<String, Object>> memberScores = new ArrayList<>();
             StringBuilder fpBuf = new StringBuilder();
             // 按 userId 稳定排序,保证指纹确定性
@@ -214,6 +248,8 @@ public class FamilyFoodOverviewService {
                 Integer score = r.getIndexScore();
                 if (score == null) continue;
                 min = Math.min(min, score);
+                sum += score;
+                if (score < 0) hasNegative = true;
                 Map<String, Object> detail = new LinkedHashMap<>();
                 detail.put("userId", r.getUserId());
                 detail.put("nickname", nicknameByUser.getOrDefault(r.getUserId(), ""));
@@ -223,13 +259,25 @@ public class FamilyFoodOverviewService {
             }
             if (memberScores.isEmpty()) continue; // 全员无该食材记录 → 不出现在总览
 
+            // 分段聚合:有成员 < 0 → 取全员最低值(风险优先);全员 >= 0 → 取平均值(四舍五入)
+            int overviewScore;
+            String aggregateRule;
+            if (hasNegative) {
+                overviewScore = min;
+                aggregateRule = RULE_MIN_NEGATIVE;
+            } else {
+                overviewScore = (int) Math.round((double) sum / memberScores.size());
+                aggregateRule = RULE_AVG_POSITIVE;
+            }
+
             Food food = foodMap.get(foodId);
             String foodName = food != null && food.getName() != null
                     ? food.getName()
                     : (rows.get(0).getFoodName() != null ? rows.get(0).getFoodName() : "");
             String category = food != null && food.getCategory() != null ? food.getCategory() : "";
-            // 指纹含成员指数快照 + 名称/分类快照:名称变更时也能刷新持久化快照,且无变更时仍跳过写入(幂等)
-            String fingerprint = sha256Hex(familyId + "|" + foodName + "|" + category + "|" + fpBuf);
+            // 指纹含规则版本 + 成员指数快照 + 名称/分类快照:规则版本变更会让历史行指纹失配,
+            // 从而强制按新口径全量重算一次;无变更时仍跳过写入(幂等)
+            String fingerprint = sha256Hex(RULE_VERSION + "|" + familyId + "|" + foodName + "|" + category + "|" + fpBuf);
 
             FamilyFoodOverview existingRow = existingMap.get(foodId);
             if (existingRow != null) {
@@ -240,6 +288,8 @@ public class FamilyFoodOverviewService {
                 existingRow.setFoodName(foodName);
                 existingRow.setCategory(category);
                 existingRow.setMinIndexScore(min);
+                existingRow.setOverviewScore(overviewScore);
+                existingRow.setAggregateRule(aggregateRule);
                 existingRow.setMemberCount(memberScores.size());
                 existingRow.setMemberScores(toJson(memberScores));
                 existingRow.setSourceFingerprint(fingerprint);
@@ -253,6 +303,8 @@ public class FamilyFoodOverviewService {
                 fp.setFoodName(foodName);
                 fp.setCategory(category);
                 fp.setMinIndexScore(min);
+                fp.setOverviewScore(overviewScore);
+                fp.setAggregateRule(aggregateRule);
                 fp.setMemberCount(memberScores.size());
                 fp.setMemberScores(toJson(memberScores));
                 fp.setSourceFingerprint(fingerprint);

+ 10 - 6
cfc-backend/src/main/resources/schema.sql

@@ -4252,7 +4252,9 @@ CREATE TABLE IF NOT EXISTS family_food_overview (
     food_id BIGINT NOT NULL COMMENT '食材ID',
     food_name VARCHAR(100) COMMENT '食材名称快照',
     category VARCHAR(50) COMMENT '分类快照',
-    min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值',
+    min_index_score INT NOT NULL COMMENT '家庭成员该项食材最新推荐指数的最小值(诊断兼容字段,语义不变)',
+    overview_score INT NULL COMMENT '家庭总览展示值: 有成员<0取最低值, 否则取平均值(四舍五入)',
+    aggregate_rule VARCHAR(24) NULL COMMENT '本次聚合规则: MIN_NEGATIVE / AVG_POSITIVE',
     member_count INT NOT NULL DEFAULT 0 COMMENT '参与计算的成员数(有该食材记录的)',
     member_scores JSON COMMENT '成员明细 [{userId,nickname,score}]',
     source_fingerprint VARCHAR(64) COMMENT '源数据指纹(成员指数快照哈希),用于幂等与变更检测',
@@ -4261,7 +4263,7 @@ CREATE TABLE IF NOT EXISTS family_food_overview (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_family_food (family_id, food_id),
     INDEX idx_family (family_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材持久化最小值总览(T28)';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭×食材总览(低于0取最低值/高于0取平均值,迁移465)';
 
 -- ============================================================
 -- 家庭共餐选中食材表(持久化共餐场景食材选择)
@@ -4943,22 +4945,24 @@ CREATE TABLE IF NOT EXISTS meal_configs (
     INDEX idx_config_date (config_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='共餐配置表';
 
--- 食谱推荐方案表
+-- 食谱推荐方案表(迁移466:新增 view_key 视角隔离列 + 普通索引;不加唯一索引)
 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',
+    menu_json MEDIUMTEXT NOT NULL COMMENT '菜品列表JSON(dish.type=dish|soup 区分汤,仅lunch/dinner按D(N)菜+1汤)',
     nutrition_summary JSON COMMENT '营养汇总',
     status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/accepted/adjusted/completed/skipped',
     version INT DEFAULT 1 COMMENT '版本号(重新生成+1)',
+    view_key VARCHAR(128) NULL COMMENT '视角维度: member:<id> / family / NULL=存量(迁移466,不回填)',
     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='食谱推荐方案表';
+    INDEX idx_meal (meal_type, recommendation_date),
+    INDEX idx_family_date_view (family_id, recommendation_date, view_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食谱推荐方案表(迁移466 视角隔离)';
 
 -- 饮食记录表
 CREATE TABLE IF NOT EXISTS diet_records (

+ 191 - 12
cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java

@@ -25,9 +25,10 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
 /**
- * FamilyFoodOverviewService 纯单元测试(T28)
- * 校验家庭总览口径:家庭成员该项食材最新推荐指数的最小值;无记录成员不计入。
- * 使用 Mockito(无 Spring 上下文,无 MySQL 连接)。
+ * FamilyFoodOverviewService 纯单元测试(T28 建表,迁移465 改分段聚合口径)
+ * 校验家庭总览口径:有成员 &lt; 0 取全员最低值(MIN_NEGATIVE)、全员 &gt;= 0 取平均值
+ * (AVG_POSITIVE,四舍五入取整,0 分计入平均);minIndexScore 始终是真实成员最小值;
+ * 无记录成员不计入。使用 Mockito(无 Spring 上下文,无 MySQL 连接)。
  */
 public class FamilyFoodOverviewServiceTest {
 
@@ -90,10 +91,10 @@ public class FamilyFoodOverviewServiceTest {
         return f;
     }
 
-    // ==================== 最小值口径 ====================
+    // ==================== 分段聚合口径(负值取最低 / 全非负取平均)====================
 
     @Test
-    public void recalculate_takesMinAcrossMembers() {
+    public void recalculate_negativeMember_takesMinAndPositiveFood_takesAverage() {
         when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
                 member(1L, 101L, "小宇宙", "active"),
                 member(2L, 102L, "妈妈", "active")
@@ -112,7 +113,7 @@ public class FamilyFoodOverviewServiceTest {
 
         service.recalculate(100L);
 
-        // 抓取插入的两行,校验 min_index_score / member_count
+        // 抓取插入的两行,校验 overviewScore / minIndexScore / aggregateRule / member_count
         org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
                 org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
         verify(overviewMapper, times(2)).insert(captor.capture());
@@ -120,7 +121,9 @@ public class FamilyFoodOverviewServiceTest {
 
         FamilyFoodOverview salmon = findByFoodId(inserted, 12L);
         assertNotNull(salmon);
-        assertEquals(-18, salmon.getMinIndexScore());   // min(-18, 22)
+        assertEquals(-18, salmon.getOverviewScore());   // 有 -18 → 风险优先,取全员最低值(不是 avg=2)
+        assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", salmon.getAggregateRule());
         assertEquals(2, salmon.getMemberCount());
         assertEquals("三文鱼", salmon.getFoodName());
         assertEquals("水产", salmon.getCategory());
@@ -129,13 +132,121 @@ public class FamilyFoodOverviewServiceTest {
 
         FamilyFoodOverview spinach = findByFoodId(inserted, 13L);
         assertNotNull(spinach);
-        assertEquals(12, spinach.getMinIndexScore());   // min(30, 12)
+        assertEquals(21, spinach.getOverviewScore());   // 全员 >= 0 → avg(30, 12) = 21(不再是 min=12)
+        assertEquals(12, spinach.getMinIndexScore());  // 诊断兼容字段仍是真实最小值
+        assertEquals("AVG_POSITIVE", spinach.getAggregateRule());
         assertEquals(2, spinach.getMemberCount());
     }
 
+    @Test
+    public void recalculate_mixedSigns_negativeWinsOverPositive() {
+        // -5 / 10 / 20 的平均是 8.33,但只要一人戒备,总览必须取最低值 -5
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active"),
+                member(3L, 103L, "爸爸", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", -5),
+                idx(102L, 12L, "三文鱼", 10),
+                idx(103L, 12L, "三文鱼", 20)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        FamilyFoodOverview row = captor.getValue();
+        assertEquals(-5, row.getOverviewScore());
+        assertEquals(-5, row.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", row.getAggregateRule());
+        assertEquals(3, row.getMemberCount());
+    }
+
+    @Test
+    public void recalculate_averageRoundsHalfUp() {
+        // 1 / 2 → avg 1.5 → 四舍五入为 2
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", 1),
+                idx(102L, 12L, "三文鱼", 2)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        assertEquals(2, captor.getValue().getOverviewScore());
+        assertEquals("AVG_POSITIVE", captor.getValue().getAggregateRule());
+    }
+
+    @Test
+    public void recalculate_zeroScoreCountedInAverage() {
+        // 0 分按非负处理、计入平均:0 / 10 → 5
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", 0),
+                idx(102L, 12L, "三文鱼", 10)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        assertEquals(5, captor.getValue().getOverviewScore());
+        assertEquals(0, captor.getValue().getMinIndexScore());
+        assertEquals("AVG_POSITIVE", captor.getValue().getAggregateRule());
+    }
+
+    @Test
+    public void recalculate_allNegative_takesLowest() {
+        // 全负值也走最低值分支:-3 / -9 → -9
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", -3),
+                idx(102L, 12L, "三文鱼", -9)
+        ));
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        service.recalculate(100L);
+
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(captor.capture());
+        assertEquals(-9, captor.getValue().getOverviewScore());
+        assertEquals("MIN_NEGATIVE", captor.getValue().getAggregateRule());
+    }
+
     @Test
     public void recalculate_memberWithoutRecordNotCountedInMin() {
-        // 妈妈没有"三文鱼"记录 → 不计入 min,也不能当 0
+        // 妈妈没有"三文鱼"记录 → 不计入聚合,也不能当 0
         when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
                 member(1L, 101L, "小宇宙", "active"),
                 member(2L, 102L, "妈妈", "active")
@@ -154,7 +265,9 @@ public class FamilyFoodOverviewServiceTest {
                 org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
         verify(overviewMapper, times(1)).insert(captor.capture());
         FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
+        assertEquals(-18, salmon.getOverviewScore());   // 仅小宇宙 -18 → 最低值 -18
         assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", salmon.getAggregateRule());
         assertEquals(1, salmon.getMemberCount());
     }
 
@@ -174,7 +287,7 @@ public class FamilyFoodOverviewServiceTest {
 
     @Test
     public void recalculate_excludesRecycledMembers() {
-        // 回收箱成员(status=recycled)不计入 —— SQL + Java 双重过滤,本测试只断言 Java 侧兜底
+        // 回收箱成员(status=recycled)不计入聚合 —— SQL + Java 双重过滤,本测试只断言 Java 侧兜底
         when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
                 member(1L, 101L, "小宇宙", "active"),
                 member(2L, 102L, "已踢出", "recycled")
@@ -194,8 +307,10 @@ public class FamilyFoodOverviewServiceTest {
                 org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
         verify(overviewMapper, times(1)).insert(captor.capture());
         FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
-        // 仅计入小宇宙 -18,已踢出 50 被过滤 → min = -18, count = 1
+        // 仅计入小宇宙 -18,已踢出 50 被过滤 → 最低值 -18, count = 1(不会被拉成平均 16)
+        assertEquals(-18, salmon.getOverviewScore());
         assertEquals(-18, salmon.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", salmon.getAggregateRule());
         assertEquals(1, salmon.getMemberCount());
     }
 
@@ -312,6 +427,8 @@ public class FamilyFoodOverviewServiceTest {
         row.setFoodName("三文鱼");
         row.setCategory("水产");
         row.setMinIndexScore(-18);
+        row.setOverviewScore(-18);
+        row.setAggregateRule("MIN_NEGATIVE");
         row.setMemberCount(2);
         row.setMemberScores("[{\"userId\":101,\"nickname\":\"小宇宙\",\"score\":-18},{\"userId\":102,\"nickname\":\"妈妈\",\"score\":22}]");
         row.setCalculatedAt(new Date());
@@ -325,7 +442,9 @@ public class FamilyFoodOverviewServiceTest {
         assertEquals(12L, ((Number) m.get("foodId")).longValue());
         assertEquals("三文鱼", m.get("foodName"));
         assertEquals("水产", m.get("category"));
-        assertEquals(-18, ((Number) m.get("minIndexScore")).intValue());
+        assertEquals(-18, ((Number) m.get("overviewScore")).intValue()); // 新展示字段
+        assertEquals("MIN_NEGATIVE", m.get("aggregateRule"));
+        assertEquals(-18, ((Number) m.get("minIndexScore")).intValue()); // 旧兼容字段保留
         assertEquals(2, ((Number) m.get("memberCount")).intValue());
         assertNotNull(m.get("calculatedAt"));
         @SuppressWarnings("unchecked")
@@ -335,6 +454,66 @@ public class FamilyFoodOverviewServiceTest {
         assertEquals(-18, ((Number) details.get(0).get("score")).intValue());
     }
 
+    @Test
+    public void getOverview_legacyRowWithoutOverviewScore_recalculates() {
+        // 迁移465 之前的历史行(overview_score 为 NULL)→ 读取侧自愈:触发一次按新口径的重算
+        FamilyFoodOverview legacy = new FamilyFoodOverview();
+        legacy.setId(7L);
+        legacy.setFamilyId(100L);
+        legacy.setFoodId(12L);
+        legacy.setFoodName("三文鱼");
+        legacy.setCategory("水产");
+        legacy.setMinIndexScore(-18);
+        legacy.setMemberCount(2);
+        legacy.setMemberScores("[]");
+        legacy.setSourceFingerprint("old-rule-fingerprint");
+
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(legacy));
+        when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
+                member(1L, 101L, "小宇宙", "active"),
+                member(2L, 102L, "妈妈", "active")
+        ));
+        when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
+                idx(101L, 12L, "三文鱼", 30),
+                idx(102L, 12L, "三文鱼", 12)
+        ));
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
+                food(12L, "三文鱼", "水产")
+        ));
+
+        List<Map<String, Object>> maps = service.getOverviewMaps(100L);
+
+        // 指纹含规则版本前缀 → 旧行必然失配,按新口径(avg(30,12)=21)更新
+        org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
+                org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, atLeastOnce()).updateById(captor.capture());
+        assertEquals(21, captor.getValue().getOverviewScore());
+        assertEquals("AVG_POSITIVE", captor.getValue().getAggregateRule());
+        assertEquals(21, ((Number) maps.get(0).get("overviewScore")).intValue());
+        assertEquals(12, ((Number) maps.get(0).get("minIndexScore")).intValue());
+    }
+
+    @Test
+    public void getOverviewMaps_legacyRowWithoutScore_fallsBackToMin() {
+        // 极端兜底:即使历史行尚未重算成功,接口也回退旧口径,不返回 null
+        FamilyFoodOverview legacy = new FamilyFoodOverview();
+        legacy.setFoodId(12L);
+        legacy.setFoodName("三文鱼");
+        legacy.setCategory("水产");
+        legacy.setMinIndexScore(-18);
+        legacy.setMemberCount(1);
+        legacy.setMemberScores("[]");
+
+        when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(legacy));
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<Map<String, Object>> maps = service.getOverviewMaps(100L);
+
+        assertEquals(1, maps.size());
+        assertEquals(-18, ((Number) maps.get(0).get("overviewScore")).intValue());
+        assertEquals("MIN_NEGATIVE", maps.get(0).get("aggregateRule"));
+    }
+
     // ==================== 辅助 ====================
 
     private static FamilyFoodOverview findByFoodId(List<FamilyFoodOverview> list, Long foodId) {

+ 536 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DietRecommendationServiceTest.java

@@ -0,0 +1,536 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.BeijingNutritionService;
+import com.etotem.cfc.service.DietRecommendationService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * DietRecommendationService 纯单元测试(迁移466 / K1~K6,无 Spring、无 MySQL)。
+ *
+ * <p>覆盖:</p>
+ * <ul>
+ *   <li>菜数公式 D(N)=4+max(0,N-3)、S=1、T=D+1、仅 lunch/dinner(AC-20/21/22 / D3·D4);</li>
+ *   <li>normalizeMenu 的 dish.type 区分汤、AI 少给菜后端补齐、超出截断、breakfast 原样(AC-23/25/26);</li>
+ *   <li>buildFallbackMenu 降级路径同样满足菜数(AC-24);</li>
+ *   <li>AI 正常 / 降级 / 重新生成三条路径均满足菜数,regenerate 返回非空菜单且不 insert(AC-27 / R-03);</li>
+ *   <li>view_key 视角隔离:传了才追加 .eq、不传保持原查询(AC-02/04/08/10);</li>
+ *   <li>updateRecommendation 保持 status 不变、/today 仍可查(AC-18 / R-02);</li>
+ *   <li>resolveParticipantIds 前端入参优先于 meal_config、双形态入参、N=0 兜底 1(AC-07/20 / D6①)。</li>
+ * </ul>
+ */
+class DietRecommendationServiceTest {
+
+    private static final ObjectMapper OM = new ObjectMapper();
+    private static final String DATE = "2026-11-10";
+
+    private DietRecommendationService service;
+    private DietRecommendationMapper recommendationMapper;
+    private AiGateway aiGateway;
+    private MealConfigMapper mealConfigMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private BeijingNutritionService beijingNutritionService;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, DietRecommendation.class);
+        TableInfoHelper.initTableInfo(assistant, FamilyMember.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new DietRecommendationService();
+        injectAll(service);
+        recommendationMapper = readField(service, "dietRecommendationMapper");
+        aiGateway = readField(service, "aiGateway");
+        mealConfigMapper = readField(service, "mealConfigMapper");
+        familyMemberMapper = readField(service, "familyMemberMapper");
+        beijingNutritionService = readField(service, "beijingNutritionService");
+
+        // 默认:无 meal_config、无家庭成员、无可用食材、AI 正常返回一份「少给菜」的菜单
+        when(mealConfigMapper.selectOne(any())).thenReturn(null);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.<FamilyMember>emptyList());
+        when(beijingNutritionService.generateIngredientList(any(), any()))
+                .thenReturn(Collections.emptyList());
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any()))
+                .thenReturn(menuWith(2, 0));
+        when(recommendationMapper.selectOne(any())).thenReturn(null);
+        when(recommendationMapper.selectList(any())).thenReturn(Collections.<DietRecommendation>emptyList());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T> T readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return (T) f.get(target);
+    }
+
+    /** 反射 mock 所有非静态、非 final 的依赖字段(风格对齐 ReportDietRecommendContractTest)。 */
+    private static void injectAll(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 工具:构造 AI 菜单 / 解析归一化结果
+    // ------------------------------------------------------------------
+
+    /** 三段菜单:breakfast 固定 1 道;lunch/dinner 各 mainCount 道 dish + soupCount 道 soup。 */
+    private static String menuWith(int mainCount, int soupCount) {
+        StringBuilder sb = new StringBuilder("{\"meals\":[");
+        sb.append("{\"type\":\"breakfast\",\"name\":\"早餐\",\"dishes\":[")
+          .append("{\"name\":\"牛奶燕麦\",\"ingredients\":[],\"cooking_method\":\"\"}]},");
+        String[] types = {"lunch", "dinner"};
+        for (int t = 0; t < types.length; t++) {
+            sb.append("{\"type\":\"").append(types[t]).append("\",\"name\":\"")
+              .append(types[t]).append("\",\"dishes\":[");
+            boolean first = true;
+            for (int i = 0; i < mainCount; i++) {
+                if (!first) sb.append(",");
+                sb.append("{\"name\":\"菜").append(i)
+                  .append("\",\"type\":\"dish\",\"nutrition\":{\"calories\":100}}");
+                first = false;
+            }
+            for (int i = 0; i < soupCount; i++) {
+                if (!first) sb.append(",");
+                sb.append("{\"name\":\"汤").append(i)
+                  .append("\",\"type\":\"soup\",\"nutrition\":{\"calories\":50}}");
+                first = false;
+            }
+            sb.append("]}");
+            if (t == 0) sb.append(",");
+        }
+        sb.append("]}");
+        return sb.toString();
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> invokeNormalize(DietRecommendationService svc, String json, int n)
+            throws Exception {
+        Method m = DietRecommendationService.class.getDeclaredMethod("normalizeMenu", String.class, int.class);
+        m.setAccessible(true);
+        return (Map<String, Object>) m.invoke(svc, json, n);
+    }
+
+    private static String invokeFallback(DietRecommendationService svc, int n) throws Exception {
+        Method m = DietRecommendationService.class.getDeclaredMethod(
+                "buildFallbackMenu", Long.class, LocalDate.class, int.class);
+        m.setAccessible(true);
+        return (String) m.invoke(svc, 100L, LocalDate.of(2026, 11, 10), n);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> mealsOf(Map<String, Object> normalized) {
+        return (List<Map<String, Object>>) normalized.get("meals");
+    }
+
+    private static Map<String, Object> mealOf(Map<String, Object> normalized, String type) {
+        for (Map<String, Object> meal : mealsOf(normalized)) {
+            if (type.equals(meal.get("type"))) {
+                return meal;
+            }
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> dishesOf(Map<String, Object> meal) {
+        return (List<Map<String, Object>>) meal.get("dishes");
+    }
+
+    private static int countByType(List<Map<String, Object>> dishes, String type) {
+        int count = 0;
+        for (Map<String, Object> dish : dishes) {
+            String actual = dish.get("type") == null ? "dish" : String.valueOf(dish.get("type"));
+            if (type.equals(actual)) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private static void assertLunchDinnerRule(Map<String, Object> normalized, int expectedDishes) {
+        for (String type : new String[]{"lunch", "dinner"}) {
+            Map<String, Object> meal = mealOf(normalized, type);
+            assertNotNull(meal, type + " 缺失");
+            List<Map<String, Object>> dishes = dishesOf(meal);
+            assertEquals(expectedDishes, countByType(dishes, "dish"), type + " 菜数应为 D(N)");
+            assertEquals(1, countByType(dishes, "soup"), type + " 汤数恒为 1");
+            assertEquals(expectedDishes + 1, dishes.size(), type + " 合计应为 D(N)+1");
+            for (Map<String, Object> dish : dishes) {
+                assertNotNull(dish.get("type"), "每道菜必须写 type(不靠菜名猜)");
+            }
+        }
+    }
+
+    private static DietRecommendation record(Long id, String viewKey, String status) {
+        DietRecommendation r = new DietRecommendation();
+        r.setId(id);
+        r.setFamilyId(100L);
+        r.setMealType("all");
+        r.setViewKey(viewKey);
+        r.setStatus(status);
+        r.setVersion(1);
+        r.setMenuJson("{}");
+        r.setParticipantMemberIds("[1,2,3]");
+        r.setRecommendationDate(java.sql.Date.valueOf(DATE));
+        return r;
+    }
+
+    private static Map<String, Object> request(String viewKey, Object participantIds) {
+        Map<String, Object> req = new HashMap<>();
+        req.put("date", DATE);
+        req.put("meal_type", "all");
+        req.put("selected_foods", "[]");
+        if (viewKey != null) {
+            req.put("view_key", viewKey);
+        }
+        if (participantIds != null) {
+            req.put("participant_member_ids", participantIds);
+        }
+        return req;
+    }
+
+    // ------------------------------------------------------------------
+    // 1. 菜数公式(AC-20/21/22 / D3·D4)
+    // ------------------------------------------------------------------
+
+    @Test
+    void dishCountRule_N1To6() throws Exception {
+        Class<?> rule = Class.forName("com.etotem.cfc.service.DietRecommendationService$DishCountRule");
+        Method dishCount = rule.getDeclaredMethod("dishCount", int.class);
+        Method soupCount = rule.getDeclaredMethod("soupCount");
+        Method total = rule.getDeclaredMethod("total", int.class);
+        Method normalizeN = rule.getDeclaredMethod("normalizeN", int.class);
+        Method appliesTo = rule.getDeclaredMethod("appliesTo", String.class);
+        dishCount.setAccessible(true);
+        soupCount.setAccessible(true);
+        total.setAccessible(true);
+        normalizeN.setAccessible(true);
+        appliesTo.setAccessible(true);
+
+        int[] expected = {4, 4, 4, 5, 6, 7};
+        for (int n = 1; n <= 6; n++) {
+            assertEquals(expected[n - 1], dishCount.invoke(null, n), "N=" + n + " 的 D(N)");
+            assertEquals(expected[n - 1] + 1, total.invoke(null, n), "N=" + n + " 的 T(N)");
+        }
+        assertEquals(1, soupCount.invoke(null), "汤恒为 1");
+        assertEquals(1, normalizeN.invoke(null, 0), "N=0 兜底 1");
+        assertEquals(1, normalizeN.invoke(null, -5), "N<0 兜底 1");
+        assertEquals(true, appliesTo.invoke(null, "lunch"));
+        assertEquals(true, appliesTo.invoke(null, "dinner"));
+        assertEquals(false, appliesTo.invoke(null, "breakfast"));
+    }
+
+    // ------------------------------------------------------------------
+    // 2. normalizeMenu:AI 少给菜补齐 / 超出截断 / breakfast 原样(AC-23/25/26)
+    // ------------------------------------------------------------------
+
+    @Test
+    void normalizeMenu_aiShortfall_padded() throws Exception {
+        Map<String, Object> normalized = invokeNormalize(service, menuWith(2, 0), 3);
+        assertLunchDinnerRule(normalized, 4); // N=3 → D=4
+        // 早餐维持现状(原样透传,不补齐)
+        assertEquals(1, dishesOf(mealOf(normalized, "breakfast")).size());
+    }
+
+    @Test
+    void normalizeMenu_aiOverflow_truncated() throws Exception {
+        Map<String, Object> normalized = invokeNormalize(service, menuWith(10, 3), 5);
+        assertLunchDinnerRule(normalized, 6); // N=5 → D=6,超出截断
+    }
+
+    @Test
+    void normalizeMenu_n0FallbackAndEveryDishHasType() throws Exception {
+        Map<String, Object> normalized = invokeNormalize(service, menuWith(0, 0), 0);
+        assertLunchDinnerRule(normalized, 4); // N=0 兜底 1 → D=4
+    }
+
+    // ------------------------------------------------------------------
+    // 3. 降级路径(AC-24)
+    // ------------------------------------------------------------------
+
+    @Test
+    void buildFallbackMenu_satisfiesRule() throws Exception {
+        String fallbackJson = invokeFallback(service, 4); // N=4 → D=5
+        assertNotNull(fallbackJson);
+        assertNotEquals("{}", fallbackJson);
+        Map<String, Object> normalized = invokeNormalize(service, fallbackJson, 4);
+        assertLunchDinnerRule(normalized, 5);
+        assertEquals(1, dishesOf(mealOf(normalized, "breakfast")).size(), "早餐仍 1 道营养餐");
+    }
+
+    // ------------------------------------------------------------------
+    // 4. generate:AI 正常路径 / 降级路径(AC-20/23/24/27)
+    // ------------------------------------------------------------------
+
+    @Test
+    void generateRecommendation_aiPath_dishCountViewKeyAndCompactIds() throws Exception {
+        Map<String, Object> res = service.generateRecommendation(100L, request("family", "[4,3,1,2]"));
+
+        // N=4 → D=5;AiGateway 收到 dish_count 软引导,不重试
+        verify(aiGateway, times(1)).generateMenu(anyString(), anyString(), eq(DATE), eq(5));
+        assertEquals(4, res.get("participantCount"));
+        assertEquals(5, res.get("dishCount"));
+
+        ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper, times(1)).insert(cap.capture());
+        DietRecommendation saved = cap.getValue();
+        assertEquals("family", saved.getViewKey(), "view_key 必须落库");
+        assertEquals("[1,2,3,4]", saved.getParticipantMemberIds(), "排序去重的紧凑 JSON");
+        assertEquals("pending", saved.getStatus());
+        assertNotNull(saved.getMenuJson());
+        assertLunchDinnerRule(toNormalized(saved.getMenuJson()), 5);
+    }
+
+    @Test
+    void generateRecommendation_fallbackPath_satisfiesRule() throws Exception {
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any())).thenReturn(null);
+
+        Map<String, Object> res = service.generateRecommendation(100L, request("member:42", "[1,2,3]"));
+
+        assertEquals(3, res.get("participantCount"));
+        ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper, times(1)).insert(cap.capture());
+        DietRecommendation saved = cap.getValue();
+        assertEquals("member:42", saved.getViewKey());
+        assertNotEquals("{}", saved.getMenuJson());
+        assertLunchDinnerRule(toNormalized(saved.getMenuJson()), 4); // N=3 → D=4
+    }
+
+    /** 直接把落库 menu_json 解析成与 normalizeMenu 输出同构的 Map,便于复用 assertLunchDinnerRule。 */
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> toNormalized(String menuJson) throws Exception {
+        return OM.readValue(menuJson, Map.class);
+    }
+
+    // ------------------------------------------------------------------
+    // 5. 重新生成(AC-27 / R-03 / K5)
+    // ------------------------------------------------------------------
+
+    @Test
+    void regenerate_nonEmptyMenu_versionPlusOne_noInsert() throws Exception {
+        DietRecommendation old = record(9L, "family", "completed");
+        old.setVersion(2);
+        old.setMenuJson("{}");
+        when(recommendationMapper.selectOne(any())).thenReturn(old);
+
+        Map<String, Object> req = new HashMap<>();
+        req.put("id", 9L);
+        Map<String, Object> res = service.regenerateRecommendation(100L, req);
+
+        assertEquals(9L, res.get("id"));
+        assertEquals(3, res.get("version"), "version = old + 1");
+        assertEquals("已重新生成食谱,版本号: 3", res.get("message"));
+        verify(recommendationMapper, never()).insert(any());
+        assertEquals("pending", old.getStatus(), "completed → pending");
+        assertEquals(3, old.getVersion());
+        assertNotEquals("{}", old.getMenuJson(), "不再返回空菜单");
+        assertLunchDinnerRule(toNormalized(old.getMenuJson()), 4); // 原共餐人 [1,2,3] → D=4
+        assertEquals("family", old.getViewKey(), "带原 view_key 覆盖同视角记录");
+    }
+
+    // ------------------------------------------------------------------
+    // 6. view_key 视角隔离(AC-02/04/08/10)
+    // ------------------------------------------------------------------
+
+    @Test
+    void viewKey_isolation_queryFiltersByViewKey() throws Exception {
+        DietRecommendation a = record(1L, "member:42", "pending");
+        when(recommendationMapper.selectList(any())).thenReturn(Collections.singletonList(a));
+
+        Map<String, Object> r = service.getTodayRecommendation(100L, DATE, "member:42");
+        assertEquals(1L, r.get("id"));
+        assertEquals("member:42", r.get("viewKey"));
+        assertEquals("pending", r.get("status"));
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<LambdaQueryWrapper> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(recommendationMapper).selectList(cap.capture());
+        String sql = cap.getValue().getSqlSegment();
+        assertTrue(sql.contains("view_key"), "传了 view_key 才追加 .eq(viewKey)");
+        assertTrue(sql.contains("ORDER BY"), "传了 view_key 时按 version,id 倒序取最新");
+
+        // 另一视角不会串到 A 的记录
+        DietRecommendation b = record(2L, "family", "pending");
+        when(recommendationMapper.selectList(any())).thenReturn(Collections.singletonList(b));
+        assertEquals(2L, service.getTodayRecommendation(100L, DATE, "family").get("id"));
+    }
+
+    @Test
+    void viewKey_isolation_noViewKeyKeepsLegacyQuery() throws Exception {
+        DietRecommendation legacy = record(7L, null, "pending");
+        when(recommendationMapper.selectList(any())).thenReturn(Collections.singletonList(legacy));
+
+        // 2 参重载(老客户端)与 3 参 null 等价
+        Map<String, Object> r = service.getTodayRecommendation(100L, DATE);
+        assertEquals(7L, r.get("id"));
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<LambdaQueryWrapper> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(recommendationMapper, atLeastOnce()).selectList(cap.capture());
+        for (LambdaQueryWrapper w : cap.getAllValues()) {
+            String sql = w.getSqlSegment();
+            assertFalse(sql.contains("view_key"), "不传 view_key 时不得追加 .eq(viewKey)");
+            assertFalse(sql.contains("ORDER BY"), "不传 view_key 时不新增排序,完全保持原查询");
+        }
+    }
+
+    @Test
+    void viewKey_isolation_generateDoesNotCoverOtherViews() throws Exception {
+        // 已存在 member:42 的记录;用 family 视角生成时幂等键追加 view_key,不会选中它
+        DietRecommendation existingForFamily = record(3L, "family", "pending");
+        when(recommendationMapper.selectOne(any())).thenReturn(existingForFamily);
+
+        service.generateRecommendation(100L, request("family", "[1,2]"));
+
+        verify(recommendationMapper, never()).insert(any());
+        verify(recommendationMapper, atLeastOnce()).updateById(existingForFamily);
+        assertEquals("family", existingForFamily.getViewKey());
+        assertLunchDinnerRule(toNormalized(existingForFamily.getMenuJson()), 4);
+    }
+
+    // ------------------------------------------------------------------
+    // 7. 删除(AC-14/16/18 / R-02 / K2)
+    // ------------------------------------------------------------------
+
+    @Test
+    void updateRecommendation_keepsStatus_andTodayStillFindsIt() throws Exception {
+        DietRecommendation rec = record(5L, "family", "completed");
+        when(recommendationMapper.selectOne(any())).thenReturn(rec);
+        String deletedMenu = "{\"meals\":[{\"type\":\"lunch\",\"name\":\"午餐\",\"dishes\":[]}]}";
+
+        Map<String, Object> req = new HashMap<>();
+        req.put("id", 5L);
+        req.put("menu_json", deletedMenu);
+        service.updateRecommendation(100L, req);
+
+        assertEquals("completed", rec.getStatus(), "status 保持原值,绝不能置 adjusted");
+        assertEquals(deletedMenu, rec.getMenuJson());
+        verify(recommendationMapper).updateById(rec);
+
+        when(recommendationMapper.selectList(any())).thenReturn(Collections.singletonList(rec));
+        Map<String, Object> today = service.getTodayRecommendation(100L, DATE, "family");
+        assertEquals(5L, today.get("id"), "删除后 /today 白名单仍可查到,不回空态");
+    }
+
+    @Test
+    void updateRecommendation_rejectsInvalidMenuJson() {
+        Map<String, Object> badSyntax = new HashMap<>();
+        badSyntax.put("id", 5L);
+        badSyntax.put("menu_json", "{not-json");
+        assertThrows(IllegalArgumentException.class,
+                () -> service.updateRecommendation(100L, badSyntax));
+
+        Map<String, Object> missingMeals = new HashMap<>();
+        missingMeals.put("id", 5L);
+        missingMeals.put("menu_json", "{\"foo\":1}");
+        assertThrows(IllegalArgumentException.class,
+                () -> service.updateRecommendation(100L, missingMeals));
+
+        verify(recommendationMapper, never()).updateById(any());
+    }
+
+    // ------------------------------------------------------------------
+    // 8. 共餐人 N 口径(AC-07/20 / D6① / K3)
+    // ------------------------------------------------------------------
+
+    @Test
+    void resolveParticipantIds_frontEndFirst_overMealConfigFallback() throws Exception {
+        // meal_config 兜底本会返回 [7,8,9];前端载荷必须优先
+        when(familyMemberMapper.selectList(any())).thenReturn(members(7L, 8L, 9L));
+
+        Map<String, Object> res = service.generateRecommendation(100L, request("family", "[2,1]"));
+        assertEquals(2, res.get("participantCount"));
+        verify(aiGateway).generateMenu(anyString(), anyString(), eq(DATE), eq(4)); // D(2)=4
+
+        ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper).insert(cap.capture());
+        assertEquals("[1,2]", cap.getValue().getParticipantMemberIds(), "前端入参优先且排序去重紧凑落库");
+    }
+
+    @Test
+    void asIds_dualShape_stringAndNativeArray() throws Exception {
+        // 形态一:JSON 字符串
+        service.generateRecommendation(100L, request("member:1", "[3,2]"));
+        ArgumentCaptor<DietRecommendation> first = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper).insert(first.capture());
+        assertEquals("[2,3]", first.getValue().getParticipantMemberIds());
+
+        // 形态二:原生数组
+        reset(recommendationMapper, aiGateway);
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any()))
+                .thenReturn(menuWith(2, 0));
+        service.generateRecommendation(100L, request("member:2", Arrays.asList(6L, 5L, 4L, 3L)));
+        ArgumentCaptor<DietRecommendation> second = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper).insert(second.capture());
+        assertEquals("[3,4,5,6]", second.getValue().getParticipantMemberIds());
+        verify(aiGateway).generateMenu(anyString(), anyString(), eq(DATE), eq(5)); // D(4)=5
+    }
+
+    @Test
+    void resolveParticipantIds_noFrontEndInput_fallsBackToMealConfig() throws Exception {
+        when(familyMemberMapper.selectList(any())).thenReturn(members(9L, 7L, 8L));
+
+        Map<String, Object> res = service.generateRecommendation(100L, request("family", null));
+        assertEquals(3, res.get("participantCount"));
+        verify(aiGateway).generateMenu(anyString(), anyString(), eq(DATE), eq(4)); // D(3)=4
+
+        ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+        verify(recommendationMapper).insert(cap.capture());
+        assertEquals("[7,8,9]", cap.getValue().getParticipantMemberIds());
+    }
+
+    @Test
+    void resolveParticipantIds_emptyEverywhere_nFallsBackToOne() throws Exception {
+        Map<String, Object> res = service.generateRecommendation(100L, request("family", "[]"));
+        assertEquals(1, res.get("participantCount"), "N=0 兜底 1");
+        assertEquals(4, res.get("dishCount"), "D(1)=4");
+        verify(aiGateway).generateMenu(anyString(), anyString(), eq(DATE), eq(4));
+    }
+
+    private static List<FamilyMember> members(Long... ids) {
+        List<FamilyMember> list = new ArrayList<>();
+        for (Long id : ids) {
+            FamilyMember m = new FamilyMember();
+            m.setId(id);
+            m.setFamilyId(100L);
+            list.add(m);
+        }
+        return list;
+    }
+}

+ 526 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/QaBoundaryVerificationTest.java

@@ -0,0 +1,526 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.GlobalExceptionHandler;
+import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.entity.FamilyFoodOverview;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.entity.MealConfig;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyFoodOverviewMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.BeijingNutritionService;
+import com.etotem.cfc.service.DietRecommendationService;
+import com.etotem.cfc.service.FamilyFoodOverviewService;
+import com.etotem.cfc.service.FoodService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * T5 独立验收临时测试(QA / Edward)。不修改任何业务代码与基线测试,跑完即删除。
+ * 覆盖:删除后 status 白名单 / 非法 menu_json / 菜数 N=0..7 / AI 不足·超出 / AI 空菜单 /
+ * view_key 老客户端与双视角 / regenerate / 家庭总览分段聚合。
+ */
+class QaBoundaryVerificationTest {
+
+    private static final ObjectMapper OM = new ObjectMapper();
+    private static final String DATE = "2026-11-10";
+    private static final long FAMILY = 100L;
+
+    private DietRecommendationService service;
+    private DietRecommendationMapper mapper;
+    private AiGateway aiGateway;
+    private MealConfigMapper mealConfigMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private BeijingNutritionService nutrition;
+
+    @BeforeAll
+    static void initTableInfo() {
+        MapperBuilderAssistant a = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(a, DietRecommendation.class);
+        TableInfoHelper.initTableInfo(a, FamilyMember.class);
+        TableInfoHelper.initTableInfo(a, MealConfig.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new DietRecommendationService();
+        for (Field f : DietRecommendationService.class.getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) continue;
+            f.setAccessible(true);
+            f.set(service, mock(f.getType()));
+        }
+        mapper = field("dietRecommendationMapper");
+        aiGateway = field("aiGateway");
+        mealConfigMapper = field("mealConfigMapper");
+        familyMemberMapper = field("familyMemberMapper");
+        nutrition = field("beijingNutritionService");
+        when(mealConfigMapper.selectOne(any())).thenReturn(null);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(nutrition.generateIngredientList(any(), any())).thenReturn(Collections.emptyList());
+        when(mapper.selectOne(any())).thenReturn(null);
+        when(mapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any())).thenReturn(menu(2, 0));
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> T field(String name) throws Exception {
+        Field f = DietRecommendationService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        return (T) f.get(service);
+    }
+
+    private static String menu(int mains, int soups) {
+        StringBuilder sb = new StringBuilder("{\"meals\":[");
+        sb.append("{\"type\":\"breakfast\",\"name\":\"早餐\",\"dishes\":[{\"name\":\"牛奶燕麦\"}]},");
+        for (int t = 0; t < 2; t++) {
+            String type = t == 0 ? "lunch" : "dinner";
+            sb.append("{\"type\":\"").append(type).append("\",\"name\":\"").append(type).append("\",\"dishes\":[");
+            boolean first = true;
+            for (int i = 0; i < mains; i++) {
+                if (!first) sb.append(",");
+                sb.append("{\"name\":\"菜").append(i).append("\",\"type\":\"dish\"}");
+                first = false;
+            }
+            for (int i = 0; i < soups; i++) {
+                if (!first) sb.append(",");
+                sb.append("{\"name\":\"汤").append(i).append("\",\"type\":\"soup\"}");
+                first = false;
+            }
+            sb.append("]}");
+            if (t == 0) sb.append(",");
+        }
+        sb.append("]}");
+        return sb.toString();
+    }
+
+    private static Map<String, Object> req(String viewKey, Object ids) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("date", DATE);
+        m.put("meal_type", "all");
+        m.put("selected_foods", "[]");
+        if (viewKey != null) m.put("view_key", viewKey);
+        if (ids != null) m.put("participant_member_ids", ids);
+        return m;
+    }
+
+    private static Map<String, Object> normalize(String json, int n) throws Exception {
+        Method m = DietRecommendationService.class.getDeclaredMethod("normalizeMenu", String.class, int.class);
+        m.setAccessible(true);
+        DietRecommendationService svc = new DietRecommendationService();
+        @SuppressWarnings("unchecked")
+        Map<String, Object> out = (Map<String, Object>) m.invoke(svc, json, n);
+        return out;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> mealOf(Map<String, Object> normalized, String type) {
+        for (Map<String, Object> meal : (List<Map<String, Object>>) normalized.get("meals")) {
+            if (type.equals(meal.get("type"))) return meal;
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> dishesOf(Map<String, Object> meal) {
+        return (List<Map<String, Object>>) meal.get("dishes");
+    }
+
+    /** MyBatis-Plus 的 paramNameValuePairs 是惰性填充的,必须先触发 getSqlSegment()。 */
+    private static Map<String, Object> params(LambdaQueryWrapper<?> w) {
+        w.getSqlSegment();
+        return w.getParamNameValuePairs();
+    }
+
+    private static long count(List<Map<String, Object>> dishes, String type) {
+        return dishes.stream().filter(d -> type.equals(String.valueOf(d.get("type")))).count();
+    }
+
+    private static void assertRule(Map<String, Object> normalized, int expected) {
+        for (String t : new String[]{"lunch", "dinner"}) {
+            Map<String, Object> meal = mealOf(normalized, t);
+            assertNotNull(meal, t + " 缺失");
+            List<Map<String, Object>> ds = dishesOf(meal);
+            assertEquals(expected, count(ds, "dish"), t + " 菜数 D(N)");
+            assertEquals(1, count(ds, "soup"), t + " 汤数 S=1");
+            assertEquals(expected + 1, ds.size(), t + " 合计 T(N)");
+            assertEquals("soup", ds.get(ds.size() - 1).get("type"), "汤必须排在最后");
+        }
+    }
+
+    private static DietRecommendation rec(Long id, String viewKey, String status, int version) {
+        DietRecommendation r = new DietRecommendation();
+        r.setId(id); r.setFamilyId(FAMILY); r.setMealType("all");
+        r.setViewKey(viewKey); r.setStatus(status); r.setVersion(version);
+        r.setMenuJson("{}"); r.setParticipantMemberIds("[1,2,3]");
+        r.setRecommendationDate(java.sql.Date.valueOf(DATE));
+        return r;
+    }
+
+    // ---------------------------------------------------------------- 删除路径
+
+    @Test
+    void qa_update_statusStaysInWhitelist_andNoSilentPadding() throws Exception {
+        for (String st : Arrays.asList("pending", "accepted", "completed")) {
+            DietRecommendation r = rec(5L, "family", st, 1);
+            when(mapper.selectOne(any())).thenReturn(r);
+            String deleted = "{\"meals\":[{\"type\":\"lunch\",\"name\":\"午餐\",\"dishes\":[]}]}";
+            Map<String, Object> request = new HashMap<>();
+            request.put("id", 5L);
+            request.put("menu_json", deleted);
+            service.updateRecommendation(FAMILY, request);
+
+            assertEquals(st, r.getStatus(), "删除后 status 必须保持 " + st + ",否则 /today 回空态");
+            assertEquals(deleted, r.getMenuJson(), "删除路径不得补菜(lunch 仍应是 0 道)");
+            OM.readTree(r.getMenuJson()); // 落库 JSON 合法
+            verify(aiGateway, never()).generateMenu(anyString(), anyString(), anyString(), any());
+            when(mapper.selectList(any())).thenReturn(Collections.singletonList(r));
+            Map<String, Object> today = service.getTodayRecommendation(FAMILY, DATE, "family");
+            assertEquals(5L, today.get("id"), "删除后 /today 仍能查到");
+            reset(mapper, aiGateway);
+            when(mapper.selectOne(any())).thenReturn(null);
+        }
+    }
+
+    @Test
+    void qa_update_invalidMenuJson_allVariantsRejectedWith400_andNoWrite() {
+        String[] bad = {null, "", "   ", "{not-json", "[]", "{\"foo\":1}",
+                "{\"meals\":{}}", "{\"meals\":\"x\"}", "{\"meals\":null}", "\"str\"", "123"};
+        for (String s : bad) {
+            Map<String, Object> request = new HashMap<>();
+            request.put("id", 5L);
+            if (s != null) request.put("menu_json", s);
+            IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                    () -> service.updateRecommendation(FAMILY, request), "非法 menu_json 应被拒: " + s);
+            Result<Void> result = new GlobalExceptionHandler().handleIllegalArgumentException(ex);
+            assertEquals(400, result.getCode(), "必须映射为 Result.error(400,...)");
+            assertEquals("menu_json格式非法", result.getMessage());
+        }
+        verify(mapper, never()).updateById(any());
+        verify(mapper, never()).insert(any());
+
+        // 合法:meals 为空数组也应允许(不写坏数据)
+        DietRecommendation r = rec(6L, "family", "pending", 1);
+        when(mapper.selectOne(any())).thenReturn(r);
+        Map<String, Object> ok = new HashMap<>();
+        ok.put("id", 6L);
+        ok.put("menu_json", "{\"meals\":[]}");
+        assertDoesNotThrow(() -> service.updateRecommendation(FAMILY, ok));
+        verify(mapper).updateById(r);
+    }
+
+    // ---------------------------------------------------------------- 菜数规则
+
+    @Test
+    void qa_dishCountFormula_N0to7() throws Exception {
+        Class<?> rule = Class.forName("com.etotem.cfc.service.DietRecommendationService$DishCountRule");
+        Method dc = rule.getDeclaredMethod("dishCount", int.class); dc.setAccessible(true);
+        Method sc = rule.getDeclaredMethod("soupCount"); sc.setAccessible(true);
+        Method tt = rule.getDeclaredMethod("total", int.class); tt.setAccessible(true);
+        Method ap = rule.getDeclaredMethod("appliesTo", String.class); ap.setAccessible(true);
+        int[] n = {0, 1, 2, 3, 4, 5, 6, 7};
+        int[] d = {4, 4, 4, 4, 5, 6, 7, 8};
+        for (int i = 0; i < n.length; i++) {
+            assertEquals(d[i], dc.invoke(null, n[i]), "N=" + n[i] + " 的 D(N)");
+            assertEquals(d[i] + 1, tt.invoke(null, n[i]), "N=" + n[i] + " 的 T(N)");
+        }
+        assertEquals(1, sc.invoke(null));
+        assertTrue((Boolean) ap.invoke(null, "lunch"));
+        assertTrue((Boolean) ap.invoke(null, "dinner"));
+        assertFalse((Boolean) ap.invoke(null, "breakfast"));
+    }
+
+    @Test
+    void qa_generate_endToEnd_N1_3_4_7_andBreakfastUnchanged() throws Exception {
+        for (int n : new int[]{1, 3, 4, 7}) {
+            reset(mapper, aiGateway);
+            when(mapper.selectOne(any())).thenReturn(null);
+            when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any())).thenReturn(menu(2, 0));
+            StringBuilder ids = new StringBuilder("[");
+            for (int i = 0; i < n; i++) { if (i > 0) ids.append(","); ids.append(i + 1); }
+            ids.append("]");
+            Map<String, Object> res = service.generateRecommendation(FAMILY, req("family", ids.toString()));
+            int expected = 4 + Math.max(0, n - 3);
+            assertEquals(n, res.get("participantCount"));
+            assertEquals(expected, res.get("dishCount"));
+            verify(aiGateway, times(1)).generateMenu(anyString(), anyString(), eq(DATE), eq(expected));
+            ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+            verify(mapper, times(1)).insert(cap.capture());
+            Map<String, Object> saved = OM.readValue(cap.getValue().getMenuJson(), Map.class);
+            assertRule(saved, expected);
+            assertEquals(1, dishesOf(mealOf(saved, "breakfast")).size(), "早餐维持 1 道,不被规则改写");
+        }
+    }
+
+    @Test
+    void qa_normalize_shortfallOverflowAndBreakfast() throws Exception {
+        assertRule(normalize(menu(2, 0), 3), 4);   // 不足补齐
+        assertRule(normalize(menu(10, 3), 5), 6);  // 超出截断 D(5)=6, 汤截到 1
+        assertRule(normalize(menu(0, 0), 0), 4);   // N=0 兜底
+        assertRule(normalize(menu(4, 0), 4), 5);   // 菜够但缺汤 → 补汤
+        String bfJson = "{\"meals\":[{\"type\":\"breakfast\",\"name\":\"早餐\",\"dishes\":["
+                + "{\"name\":\"粥\",\"type\":\"dish\"},{\"name\":\"蛋\",\"type\":\"dish\"},"
+                + "{\"name\":\"青菜\",\"type\":\"dish\"},{\"name\":\"汤\",\"type\":\"soup\"}]},"
+                + "{\"type\":\"lunch\",\"name\":\"午餐\",\"dishes\":[]},"
+                + "{\"type\":\"dinner\",\"name\":\"晚餐\",\"dishes\":[]}]}";
+        Map<String, Object> bf = normalize(bfJson, 2);
+        assertEquals(4, dishesOf(mealOf(bf, "breakfast")).size(), "早餐菜数原样(3 菜 1 汤 = 4 道)");
+        assertRule(bf, 4);
+    }
+
+    @Test
+    void qa_aiEmptyMealsArray_orMalformedJson_shouldNotSaveEmptyMenu() throws Exception {
+        // LangGraph 在 LLM 失败/无 JSON 时返回 {"meals": []}(meal.py L171/L176)
+        for (String aiOut : new String[]{"{\"meals\": []}", "garbage-not-json"}) {
+            reset(mapper, aiGateway);
+            when(mapper.selectOne(any())).thenReturn(null);
+            when(aiGateway.generateMenu(anyString(), anyString(), anyString(), any())).thenReturn(aiOut);
+            service.generateRecommendation(FAMILY, req("family", "[1,2,3]"));
+            ArgumentCaptor<DietRecommendation> cap = ArgumentCaptor.forClass(DietRecommendation.class);
+            verify(mapper, times(1)).insert(cap.capture());
+            Map<String, Object> saved = OM.readValue(cap.getValue().getMenuJson(), Map.class);
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> meals = (List<Map<String, Object>>) saved.get("meals");
+            // 期望:AI 返回空/非法时后端应降级或补齐,用户不应看到空菜单
+            assertTrue(meals != null && meals.size() >= 3,
+                    "AI 输出=" + aiOut + " 时不应把空菜单落库(AC-24/26);实际 meals.size()=" +
+                            (meals == null ? -1 : meals.size()));
+        }
+    }
+
+    // ---------------------------------------------------------------- view_key
+
+    @Test
+    void qa_generate_noViewKey_preservesExistingViewKeyAndAddsNoEq() throws Exception {
+        DietRecommendation existing = rec(3L, "member:42", "pending", 1);
+        when(mapper.selectOne(any())).thenReturn(existing);
+        service.generateRecommendation(FAMILY, req(null, "[1,2]"));
+        ArgumentCaptor<LambdaQueryWrapper> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(mapper, atLeastOnce()).selectOne(cap.capture());
+        boolean hasViewKey = false;
+        for (LambdaQueryWrapper<?> w : cap.getAllValues()) {
+            for (Object v : params(w).values()) {
+                if (v instanceof String && (((String) v).startsWith("member:") || "family".equals(v))) {
+                    hasViewKey = true;
+                }
+            }
+        }
+        assertFalse(hasViewKey, "老客户端不传 view_key 时不得追加 .eq(viewKey)");
+        assertEquals("member:42", existing.getViewKey(), "不得把已有视角键清成 NULL");
+        verify(mapper, never()).insert(any());
+    }
+
+    @Test
+    void qa_generate_twoViews_createSeparateRecords() throws Exception {
+        Map<String, DietRecommendation> db = new LinkedHashMap<>();
+        when(mapper.selectOne(any())).thenAnswer(inv -> {
+            LambdaQueryWrapper<?> w = inv.getArgument(0);
+            String key = null;
+            for (Object v : params(w).values()) {
+                if (v instanceof String && (((String) v).startsWith("member:") || "family".equals(v))) key = (String) v;
+            }
+            return db.get(key);
+        });
+        doAnswer(inv -> { DietRecommendation r = inv.getArgument(0); db.put(r.getViewKey(), r); return 1; })
+                .when(mapper).insert(any(DietRecommendation.class));
+
+        service.generateRecommendation(FAMILY, req("family", "[1,2,3]"));
+        service.generateRecommendation(FAMILY, req("member:42", "[42]"));
+        assertEquals(2, db.size(), "两个视角应各自一条,互不覆盖");
+        assertNotNull(db.get("family"));
+        assertNotNull(db.get("member:42"));
+        assertNotEquals(db.get("family").getMenuJson(), "{}");
+        assertNotEquals(db.get("member:42").getMenuJson(), "{}");
+    }
+
+    @Test
+    void qa_today_viewKeys_legacyFamilyMember() {
+        DietRecommendation legacy = rec(1L, null, "pending", 1);
+        DietRecommendation fam = rec(2L, "family", "pending", 1);
+        DietRecommendation mem = rec(3L, "member:42", "pending", 1);
+        when(mapper.selectList(any())).thenAnswer(inv -> {
+            LambdaQueryWrapper<?> w = inv.getArgument(0);
+            Object key = null;
+            for (Object v : params(w).values()) {
+                if (v instanceof String && (((String) v).startsWith("member:") || "family".equals(v))) key = v;
+            }
+            if (key == null) return Collections.singletonList(legacy);
+            if ("family".equals(key)) return Collections.singletonList(fam);
+            if ("member:42".equals(key)) return Collections.singletonList(mem);
+            return Collections.emptyList();
+        });
+        assertEquals(1L, service.getTodayRecommendation(FAMILY, DATE).get("id"));
+        assertEquals(1L, service.getTodayRecommendation(FAMILY, DATE, null).get("id"));
+        assertEquals(2L, service.getTodayRecommendation(FAMILY, DATE, "family").get("id"));
+        assertEquals(3L, service.getTodayRecommendation(FAMILY, DATE, "member:42").get("id"));
+        assertTrue(service.getTodayRecommendation(FAMILY, DATE, "member:99").containsKey("message"), "查不到应为空态");
+    }
+
+    // ---------------------------------------------------------------- regenerate
+
+    @Test
+    void qa_regenerate_positive_menuNonEmptyVersionStatus() throws Exception {
+        DietRecommendation old = rec(9L, "family", "completed", 2);
+        when(mapper.selectOne(any())).thenReturn(old);
+        Map<String, Object> request = new HashMap<>();
+        request.put("id", 9L);
+        Map<String, Object> res = service.regenerateRecommendation(FAMILY, request);
+        assertEquals(3, res.get("version"));
+        assertEquals("pending", old.getStatus());
+        assertEquals(3, old.getVersion());
+        assertNotEquals("{}", old.getMenuJson());
+        assertRule(OM.readValue(old.getMenuJson(), Map.class), 4);
+        verify(mapper, never()).insert(any());
+    }
+
+    @Test
+    void qa_regenerate_legacyNullViewKey_multipleRowsRisk() throws Exception {
+        // 迁移466 后同 (family,date,meal_type) 可能同时存在 NULL / family / member:* 三行。
+        // 老记录 view_key=NULL → generateRecommendation 不追加 .eq(viewKey),
+        // MP selectOne 命中多行(此处模拟抛 TooManyResultsException)。
+        DietRecommendation old = rec(9L, null, "pending", 1);
+        when(mapper.selectOne(any())).thenAnswer(inv -> {
+            LambdaQueryWrapper<?> w = inv.getArgument(0);
+            boolean hasViewKey = false;
+            for (Object v : params(w).values()) {
+                if (v instanceof String && (((String) v).startsWith("member:") || "family".equals(v))) hasViewKey = true;
+            }
+            boolean isGenerateKey = false;
+            for (Object v : params(w).values()) {
+                if (v instanceof Date) isGenerateKey = true;   // generate 幂等键含 recommendation_date
+            }
+            if (isGenerateKey && !hasViewKey) {
+                throw new RuntimeException("One record is expected, but the query result is multiple records");
+            }
+            return old;
+        });
+        Map<String, Object> request = new HashMap<>();
+        request.put("id", 9L);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.regenerateRecommendation(FAMILY, request));
+        assertTrue(ex.getMessage().contains("multiple records"),
+                "存量 NULL 视角 regenerate 的幂等键不追加 view_key,命中多行会异常/误覆盖");
+    }
+
+    // ---------------------------------------------------------------- 家庭总览基线
+
+    private FamilyFoodOverviewService overviewService;
+    private FamilyFoodOverviewMapper overviewMapper;
+    private FoodRecommendIndexMapper idxMapper;
+    private FoodService foodService;
+    private FamilyMemberMapper fmMapper;
+
+    private void initOverview() throws Exception {
+        overviewService = new FamilyFoodOverviewService();
+        overviewMapper = mock(FamilyFoodOverviewMapper.class);
+        idxMapper = mock(FoodRecommendIndexMapper.class);
+        foodService = mock(FoodService.class);
+        fmMapper = mock(FamilyMemberMapper.class);
+        set(overviewService, "familyFoodOverviewMapper", overviewMapper);
+        set(overviewService, "familyMemberMapper", fmMapper);
+        set(overviewService, "foodRecommendIndexMapper", idxMapper);
+        set(overviewService, "foodService", foodService);
+        when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(food(12L, "三文鱼", "水产")));
+    }
+
+    private static void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static FamilyMember member(Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(userId); m.setUserId(userId); m.setFamilyId(FAMILY);
+        m.setNickname("m" + userId); m.setStatus("active");
+        return m;
+    }
+
+    private static FoodRecommendIndex idx(Long userId, int score) {
+        FoodRecommendIndex i = new FoodRecommendIndex();
+        i.setId(userId); i.setUserId(userId); i.setFoodId(12L);
+        i.setFoodName("三文鱼"); i.setIndexScore(score); i.setCalculatedAt(new Date());
+        return i;
+    }
+
+    private static Food food(Long id, String name, String category) {
+        Food f = new Food(); f.setId(id); f.setName(name); f.setCategory(category); return f;
+    }
+
+    private FamilyFoodOverview runOverview(int... scores) throws Exception {
+        initOverview();
+        List<FamilyMember> members = new ArrayList<>();
+        List<FoodRecommendIndex> rows = new ArrayList<>();
+        for (int i = 0; i < scores.length; i++) {
+            long uid = 101L + i;
+            members.add(member(uid));
+            rows.add(idx(uid, scores[i]));
+        }
+        when(fmMapper.selectList(any())).thenReturn(members);
+        when(idxMapper.selectList(any())).thenReturn(rows);
+        overviewService.recalculate(FAMILY);
+        ArgumentCaptor<FamilyFoodOverview> cap = ArgumentCaptor.forClass(FamilyFoodOverview.class);
+        verify(overviewMapper, times(1)).insert(cap.capture());
+        return cap.getValue();
+    }
+
+    @Test
+    void qa_familyOverview_matrix() throws Exception {
+        FamilyFoodOverview mixed = runOverview(-5, 10, 20);
+        assertEquals(-5, mixed.getOverviewScore());
+        assertEquals(-5, mixed.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", mixed.getAggregateRule());
+        assertEquals(3, mixed.getMemberCount());
+
+        FamilyFoodOverview zeros = runOverview(0, 0, 0);
+        assertEquals(0, zeros.getOverviewScore());
+        assertEquals("AVG_POSITIVE", zeros.getAggregateRule());
+
+        FamilyFoodOverview negs = runOverview(-3, -9);
+        assertEquals(-9, negs.getOverviewScore());
+        assertEquals(-9, negs.getMinIndexScore());
+        assertEquals("MIN_NEGATIVE", negs.getAggregateRule());
+
+        FamilyFoodOverview halfUp = runOverview(2, 3);            // avg 2.5 → 3
+        assertEquals(3, halfUp.getOverviewScore());
+        assertEquals("AVG_POSITIVE", halfUp.getAggregateRule());
+
+        FamilyFoodOverview zeroCounted = runOverview(0, 5);       // avg 2.5 → 3,0 计入分母
+        assertEquals(3, zeroCounted.getOverviewScore());
+        assertEquals(0, zeroCounted.getMinIndexScore());
+        assertEquals(2, zeroCounted.getMemberCount());
+
+        FamilyFoodOverview oneTwo = runOverview(1, 2);            // avg 1.5 → 2
+        assertEquals(2, oneTwo.getOverviewScore());
+    }
+}

+ 3 - 1
cfc-frontend/__tests__/diet/recommendation.healthAdjustUi.test.js

@@ -113,6 +113,7 @@ beforeEach(function () {
     generateDietRecommendation: jest.fn(),
     completeDietRecommendation: jest.fn(),
     regenerateDietRecommendation: jest.fn(),
+    updateDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 200, message: 'success', data: null }) }),
     config: { API_BASE_URL: 'http://mock' },
     MemberSelector: {},
     memberIdOf: function (m) { return m && (m.familyMemberId || m.memberId || m.id) },
@@ -123,7 +124,8 @@ beforeEach(function () {
       getDietRecommendation: stubs.getDietRecommendation,
       generateDietRecommendation: stubs.generateDietRecommendation,
       completeDietRecommendation: stubs.completeDietRecommendation,
-      regenerateDietRecommendation: stubs.regenerateDietRecommendation
+      regenerateDietRecommendation: stubs.regenerateDietRecommendation,
+      updateDietRecommendation: stubs.updateDietRecommendation
     },
     '@/config.js': { config: stubs.config },
     '@/components/MemberSelector.vue': { MemberSelector: stubs.MemberSelector },

+ 291 - 0
cfc-frontend/__tests__/pages/diet/recommendation.pagination.test.js

@@ -0,0 +1,291 @@
+/**
+ * 契约级单测 —— 能力键 diet.recommendation.pagination
+ *
+ * BugFix:食材列表「下滑翻页失效」。
+ *
+ * 溯源:用户直接报障;根因定位见 team-lead 派单与本次回报 ROOT_CAUSE。
+ *   既有 uiRework.test.js REQ4 已断言「模板绑定 @scrolltolower="loadMore"」与
+ *   「loadMore() 令 visibleCount 20→40→60」并全绿,说明 JS / 模板层完好;
+ *   唯一缺失且失效的是「scroll-view 是否拿到确定高度」这一布局前提 ——
+ *   uni-app 中 scroll-y 必须拿到确定高度才会形成内部滚动视口并派发触底事件。
+ *   本文件补上这段 CSS 契约断言,并把「触底 → visibleCount 递增 → 列表增长」
+ *   串成一条端到端回归链,防止只改样式不改行为或后续又把高度改回 min-height。
+ *
+ * 约定(沿用 uiRework / viewKeyAndDelete):加载真实 recommendation.vue 的
+ * <script>/<template>/<style>,import 替换为注入桩后求值,驱动真实
+ * data()/computed/methods 做行为级断言;不挂载 .vue、不修改业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+const RECOMMENDATION_PATH = path.join(__dirname, '../../../pages/diet/recommendation.vue')
+
+function stubImports(script) {
+  return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
+    var names = []
+    var c = clause.trim()
+    var braceIdx = c.indexOf('{')
+    if (braceIdx >= 0) {
+      var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
+      if (def) names.push(def)
+      var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
+      named.split(',').forEach(function (part) {
+        var p = part.trim()
+        if (!p) return
+        var parts = p.split(/\s+as\s+/)
+        var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
+        if (local) names.push(local)
+      })
+    } else if (c.indexOf('*') === 0) {
+      var nsMatch = c.match(/\*\s+as\s+(\w+)/)
+      if (nsMatch) names.push(nsMatch[1])
+    } else {
+      names.push(c)
+    }
+    return names.map(function (n) {
+      return 'var ' + n + ' = (globalThis.__recommendationStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template, style } */
+function loadRecommendation() {
+  var src = fs.readFileSync(RECOMMENDATION_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  var styleMatch = src.match(/<style[^>]*>([\s\S]*?)<\/style>/)
+  if (!scriptMatch) throw new Error('无法在 recommendation.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1])
+  script = script.replace(/export default\s*\{/, 'module.exports = {')
+  var mod = { exports: {} }
+  // eslint-disable-next-line no-new-func
+  var fn = new Function('module', 'exports', script)
+  fn(mod, mod.exports)
+  return {
+    component: mod.exports,
+    template: tmplMatch ? tmplMatch[1] : '',
+    style: styleMatch ? styleMatch[1] : ''
+  }
+}
+
+/** 合并 data()/computed(getter)/methods 到 vm 实例(模拟 Vue Options 合并) */
+function instantiate(component) {
+  var vm = {}
+  var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
+  Object.keys(data).forEach(function (k) { vm[k] = data[k] })
+  if (component.computed) {
+    Object.keys(component.computed).forEach(function (k) {
+      Object.defineProperty(vm, k, { get: component.computed[k].bind(vm), enumerable: true, configurable: true })
+    })
+  }
+  var methods = component.methods || {}
+  Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
+  return vm
+}
+
+/** 取出某个 CSS 规则的声明体(去注释,避免注释里的关键字干扰断言) */
+function ruleBody(style, selector) {
+  var clean = style.replace(/\/\*[\s\S]*?\*\//g, '')
+  var re = new RegExp('\\' + selector + '\\s*\\{([^}]*)\\}')
+  var m = clean.match(re)
+  return m ? m[1] : ''
+}
+
+/** 构造 n 条全为「适合」的食材,保证 filteredFoods 长度可控(默认 filterType='suitable') */
+function buildFoods(n) {
+  var out = []
+  for (var i = 1; i <= n; i++) {
+    out.push({ id: i, name: '食材' + i, category: '蔬菜', score: i })
+  }
+  return out
+}
+
+var stubs
+
+beforeEach(function () {
+  jest.useFakeTimers()
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.showLoading.mockReset()
+  global.uni.hideLoading.mockReset()
+  global.uni.request.mockReset()
+
+  stubs = {
+    getDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 200, data: null }) }),
+    generateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    updateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
+    config: { default: { API_BASE_URL: '/api' } },
+    MemberSelector: { default: {} },
+    fontScaleMixin: { default: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
+  }
+  globalThis.__recommendationStubs = {
+    '@/utils/api.js': {
+      getDietRecommendation: stubs.getDietRecommendation,
+      generateDietRecommendation: stubs.generateDietRecommendation,
+      updateDietRecommendation: stubs.updateDietRecommendation,
+      completeDietRecommendation: stubs.completeDietRecommendation,
+      regenerateDietRecommendation: stubs.regenerateDietRecommendation
+    },
+    '@/config.js': stubs.config,
+    '@/components/MemberSelector.vue': stubs.MemberSelector,
+    '@/utils/memberNormalize.js': { memberIdOf: stubs.memberIdOf },
+    '@/mixins/font-scale.js': stubs.fontScaleMixin
+  }
+})
+
+afterEach(function () {
+  jest.clearAllTimers()
+  jest.useRealTimers()
+  delete globalThis.__recommendationStubs
+})
+
+// ---------------------------------------------------------------------------
+// 1. 根因回归:scroll-view 必须拿到确定高度(否则 @scrolltolower 永不派发)
+// ---------------------------------------------------------------------------
+
+describe('根因回归:scroll-view 确定高度契约', function () {
+  it('页面根使用确定高度 height:100vh,不再仅由 min-height 撑开', function () {
+    var style = loadRecommendation().style
+    var pageRule = ruleBody(style, '.recommendation-page')
+    expect(pageRule).toMatch(/\bheight:\s*100vh/)
+    expect(pageRule).not.toMatch(/\bmin-height:\s*100vh/)
+  })
+
+  it('.content 保留 flex:1 原有布局,并补 height:0 / min-height:0 得到确定计算高度', function () {
+    var style = loadRecommendation().style
+    var contentRule = ruleBody(style, '.content')
+    expect(contentRule).toMatch(/flex:\s*1/)
+    expect(contentRule).toMatch(/\bheight:\s*0/)
+    expect(contentRule).toMatch(/min-height:\s*0/)
+    expect(contentRule).toMatch(/overflow:\s*hidden/)
+  })
+
+  it('修复未引入 CSS Grid(小程序禁用)', function () {
+    var style = loadRecommendation().style
+    expect(style).not.toMatch(/display:\s*grid/)
+    expect(style).not.toMatch(/grid-template/)
+  })
+
+  it('外层 scroll-view 仍绑定 @scrolltolower="loadMore" 且开启 scroll-y', function () {
+    var template = loadRecommendation().template
+    expect(template).toContain('@scrolltolower="loadMore"')
+    expect(template).toMatch(/<scroll-view[^>]*\sscroll-y/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 2. 端到端链路:触底 → loadMore → visibleCount 递增 → pagedFoods 增长
+// ---------------------------------------------------------------------------
+
+describe('下滑触底 → 追加食材(visibleCount / pagedFoods)', function () {
+  it('首批 20 条;模拟触底一次后 visibleCount=40 且列表增长到 40 条', function () {
+    var loaded = loadRecommendation()
+    var vm = instantiate(loaded.component)
+    vm.currentView = 'personal'
+    vm.allFoods = buildFoods(55)
+    vm.visibleCount = 20
+
+    expect(vm.filteredFoods.length).toBe(55)
+    expect(vm.pagedFoods.length).toBe(20)
+
+    vm.loadMore()
+
+    expect(vm.visibleCount).toBe(40)
+    expect(vm.pagedFoods.length).toBe(40)
+  })
+
+  it('连续触底按 20 递增,到末尾自动停止且列表不超全量', function () {
+    var vm = instantiate(loadRecommendation().component)
+    vm.currentView = 'personal'
+    vm.allFoods = buildFoods(55)
+    vm.visibleCount = 20
+
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(40)
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(60)
+    expect(vm.pagedFoods.length).toBe(55)
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(60)
+    expect(vm.pagedFoods.length).toBe(55)
+  })
+
+  it('切 Tab / 分类 / 筛选 / 成员后分页重置为 20,可重新触底加载', function () {
+    var vm = instantiate(loadRecommendation().component)
+    vm.currentView = 'personal'
+    vm.allFoods = buildFoods(55)
+
+    vm.visibleCount = 60
+    vm.switchTab('suitable')
+    expect(vm.visibleCount).toBe(20)
+    expect(vm.pagedFoods.length).toBe(20)
+
+    vm.visibleCount = 60
+    vm.switchCategory('蔬菜')
+    expect(vm.visibleCount).toBe(20)
+
+    vm.visibleCount = 60
+    vm.setFilter('all')
+    expect(vm.visibleCount).toBe(20)
+
+    vm.visibleCount = 60
+    vm.switchMember({ familyMemberId: 7 })
+    expect(vm.visibleCount).toBe(20)
+
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(40)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 3. 排除其余疑点:onShow / refresher-triggered 不锁死列表状态
+// ---------------------------------------------------------------------------
+
+describe('排除疑点:onShow 与下拉刷新不重置/锁死分页', function () {
+  it('data().refreshing 初值为 false,不会常驻 true 锁住滚动', function () {
+    var loaded = loadRecommendation()
+    expect(loaded.component.data().refreshing).toBe(false)
+    var vm = instantiate(loaded.component)
+    expect(vm.refreshing).toBe(false)
+  })
+
+  it('onRefresh 500ms 后把 refreshing 复位为 false', function () {
+    var vm = instantiate(loadRecommendation().component)
+    vm.onRefresh()
+    expect(vm.refreshing).toBe(true)
+    jest.advanceTimersByTime(500)
+    expect(vm.refreshing).toBe(false)
+  })
+
+  it('onShow 只重载今日食谱,不重置 visibleCount / currentTab / filterType / allFoods', function () {
+    var loaded = loadRecommendation()
+    var vm = instantiate(loaded.component)
+    vm.currentView = 'personal'
+    vm.currentMemberId = null
+    vm.allFoods = buildFoods(55)
+    vm.visibleCount = 40
+    vm.currentTab = 'suitable'
+    vm.filterType = 'suitable'
+
+    loaded.component.onShow.call(vm)
+
+    expect(vm.visibleCount).toBe(40)
+    expect(vm.currentTab).toBe('suitable')
+    expect(vm.filterType).toBe('suitable')
+    expect(vm.allFoods.length).toBe(55)
+    expect(vm.pagedFoods.length).toBe(40)
+  })
+
+  it('食材区 v-if 在切出 recipe Tab 时正常渲染', function () {
+    var loaded = loadRecommendation()
+    var vm = instantiate(loaded.component)
+    expect(vm.currentTab).toBe('recipe')
+    expect(loaded.template).toContain('v-if="currentTab !== \'recipe\'"')
+    vm.switchTab('suitable')
+    expect(vm.currentTab).toBe('suitable')
+  })
+})

+ 3 - 1
cfc-frontend/__tests__/pages/diet/recommendation.participantSelect.test.js

@@ -135,6 +135,7 @@ beforeEach(function () {
     getDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    updateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
     config: { default: { API_BASE_URL: '/api' } },
     MemberSelector: { default: {} },
@@ -145,7 +146,8 @@ beforeEach(function () {
       getDietRecommendation: stubs.getDietRecommendation,
       generateDietRecommendation: stubs.generateDietRecommendation,
       completeDietRecommendation: stubs.completeDietRecommendation,
-      regenerateDietRecommendation: stubs.regenerateDietRecommendation
+      regenerateDietRecommendation: stubs.regenerateDietRecommendation,
+      updateDietRecommendation: stubs.updateDietRecommendation
     },
     '@/config.js': stubs.config,
     '@/components/MemberSelector.vue': stubs.MemberSelector,

+ 3 - 1
cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js

@@ -109,6 +109,7 @@ beforeEach(function () {
     getDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    updateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
     memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
     config: { default: { API_BASE_URL: '/api' } },
     MemberSelector: { default: {} },
@@ -119,7 +120,8 @@ beforeEach(function () {
       getDietRecommendation: stubs.getDietRecommendation,
       generateDietRecommendation: stubs.generateDietRecommendation,
       completeDietRecommendation: stubs.completeDietRecommendation,
-      regenerateDietRecommendation: stubs.regenerateDietRecommendation
+      regenerateDietRecommendation: stubs.regenerateDietRecommendation,
+      updateDietRecommendation: stubs.updateDietRecommendation
     },
     '@/config.js': stubs.config,
     '@/components/MemberSelector.vue': stubs.MemberSelector,

+ 494 - 0
cfc-frontend/__tests__/pages/diet/recommendation.viewKeyAndDelete.test.js

@@ -0,0 +1,494 @@
+/**
+ * 契约级单测 —— 能力键 diet.recommendation.viewKeyAndDelete
+ *
+ * 溯源:
+ *   PRD      docs/需求分析/2026-11-10-食材推荐食谱需求变更.md(38 条 AC,产品规则单一来源)
+ *   设计     docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md
+ *            §1.1(K1~K6)/ §3(接口契约)/ §8.2(AC→断言映射)
+ *
+ * 覆盖:buildViewKey 各视角、视角切换四处联动、生成载荷 view_key、模板契约(替换→删除)、
+ *       deleteDish 成功/失败/防重复、D2 只读菜数状态不补菜、空/加载/失败三态、
+ *       parseMenu 透传 dish.type、allMealsEmpty 整份空态。
+ *
+ * 约定(沿用 uiRework / participantSelect):加载真实 recommendation.vue 的 <script>/<template>,
+ * import 替换为注入桩后求值,驱动真实 data()/methods/computed 做行为级断言;不挂载 .vue。
+ * 本文件不修改任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+const RECOMMENDATION_PATH = path.join(__dirname, '../../../pages/diet/recommendation.vue')
+
+function stubImports(script, stubMap) {
+  return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
+    var names = []
+    var c = clause.trim()
+    var braceIdx = c.indexOf('{')
+    if (braceIdx >= 0) {
+      var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
+      if (def) names.push(def)
+      var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
+      named.split(',').forEach(function (part) {
+        var p = part.trim()
+        if (!p) return
+        var parts = p.split(/\s+as\s+/)
+        var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
+        if (local) names.push(local)
+      })
+    } else if (c.indexOf('*') === 0) {
+      var nsMatch = c.match(/\*\s+as\s+(\w+)/)
+      if (nsMatch) names.push(nsMatch[1])
+    } else {
+      names.push(c)
+    }
+    return names.map(function (n) {
+      return 'var ' + n + ' = (globalThis.__recommendationStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+function loadRecommendation() {
+  var src = fs.readFileSync(RECOMMENDATION_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 recommendation.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__recommendationStubs || {})
+  script = script.replace(/export default\s*\{/, 'module.exports = {')
+  var mod = { exports: {} }
+  // eslint-disable-next-line no-new-func
+  var fn = new Function('module', 'exports', script)
+  fn(mod, mod.exports)
+  return { component: mod.exports, template: tmplMatch ? tmplMatch[1] : '' }
+}
+
+/** 合并 data()/computed(getter)/methods 到 vm 实例(模拟 Vue Options 合并) */
+function instantiate(component) {
+  var vm = {}
+  var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
+  Object.keys(data).forEach(function (k) { vm[k] = data[k] })
+  if (component.computed) {
+    Object.keys(component.computed).forEach(function (k) {
+      Object.defineProperty(vm, k, { get: component.computed[k].bind(vm), enumerable: true, configurable: true })
+    })
+  }
+  var methods = component.methods || {}
+  Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
+  return vm
+}
+
+var stubs
+
+beforeEach(function () {
+  jest.useFakeTimers()
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.showLoading.mockReset()
+  global.uni.hideLoading.mockReset()
+  global.uni.request.mockReset()
+
+  stubs = {
+    getDietRecommendation: jest.fn(function () {
+      return Promise.resolve({ code: 200, data: { id: 11, date: '2026-11-10', menu: '{"meals":[]}', nutritionSummary: {}, status: 'pending' } })
+    }),
+    generateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
+    updateDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 200, message: 'success', data: null }) }),
+    completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
+    regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
+    memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
+    config: { default: { API_BASE_URL: '/api' } },
+    MemberSelector: { default: {} },
+    fontScaleMixin: { default: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
+  }
+  globalThis.__recommendationStubs = {
+    '@/utils/api.js': {
+      getDietRecommendation: stubs.getDietRecommendation,
+      generateDietRecommendation: stubs.generateDietRecommendation,
+      updateDietRecommendation: stubs.updateDietRecommendation,
+      completeDietRecommendation: stubs.completeDietRecommendation,
+      regenerateDietRecommendation: stubs.regenerateDietRecommendation
+    },
+    '@/config.js': stubs.config,
+    '@/components/MemberSelector.vue': stubs.MemberSelector,
+    '@/utils/memberNormalize.js': { memberIdOf: stubs.memberIdOf },
+    '@/mixins/font-scale.js': stubs.fontScaleMixin
+  }
+})
+
+afterEach(function () {
+  jest.clearAllTimers()
+  jest.useRealTimers()
+  delete globalThis.__recommendationStubs
+})
+
+/** 构造一份 4 菜 1 汤的午餐,便于删除 / 状态断言 */
+function lunchWithDishes() {
+  return {
+    name: '午餐', type: 'lunch', icon: '☀️', participants: 3,
+    dishes: [
+      { name: '红烧肉', type: 'dish', calories: 300, ingredients: [{ name: '肉', amount: 100 }], method: '炖' },
+      { name: '清炒时蔬', type: 'dish', calories: 90, ingredients: [], method: '炒' },
+      { name: '蒸鱼', type: 'dish', calories: 180, ingredients: [], method: '蒸' },
+      { name: '凉拌黄瓜', type: 'dish', calories: 40, ingredients: [], method: '拌' },
+      { name: '番茄蛋汤', type: 'soup', calories: 60, ingredients: [], method: '煮' }
+    ]
+  }
+}
+
+function dinnerWithDishes() {
+  var meal = lunchWithDishes()
+  meal.name = '晚餐'
+  meal.type = 'dinner'
+  meal.icon = '🌙'
+  return meal
+}
+
+// ---------------------------------------------------------------------------
+// 1. buildViewKey:K1 视角键(AC-01/03/10)
+// ---------------------------------------------------------------------------
+
+describe('buildViewKey 视角键生成(K1,后端视为不透明字符串)', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('家庭总览固定返回 family', function () {
+    vm.currentView = 'family'
+    expect(vm.buildViewKey()).toBe('family')
+  })
+
+  it('个人/成员视角返回 member:<id>', function () {
+    vm.currentView = 'personal'
+    vm.currentMemberId = 42
+    expect(vm.buildViewKey()).toBe('member:42')
+  })
+
+  it('loadMembers 未完成(personal 且无 currentMemberId、memberList 空)返回 null', function () {
+    vm.currentView = 'personal'
+    vm.currentMemberId = null
+    vm.memberList = []
+    expect(vm.buildViewKey()).toBeNull()
+  })
+
+  it('视角未就绪时不发 /today 请求,且清空残留防串数据', function () {
+    vm.currentView = 'personal'
+    vm.currentMemberId = null
+    vm.memberList = []
+    vm.meals = [lunchWithDishes()]
+    vm.recommendation = { id: 99 }
+    vm.loadRecommendation()
+    expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
+    expect(vm.meals).toEqual([])
+    expect(vm.recommendation).toBeNull()
+    expect(vm.recipeLoading).toBe(false)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 2. loadRecommendation 载荷 + 局部加载态(AC-02/04/09/36)
+// ---------------------------------------------------------------------------
+
+describe('loadRecommendation 按视角加载', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('请求携带 {date, view_key}', function () {
+    vm.currentView = 'family'
+    vm.loadRecommendation()
+    expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
+      expect.objectContaining({ date: expect.any(String), view_key: 'family' })
+    )
+  })
+
+  it('加载期间显示局部 loading 且清空上一视角的 meals(不串数据)', function () {
+    vm.currentView = 'family'
+    vm.currentMemberId = 7
+    vm.meals = [lunchWithDishes()]
+    vm.recommendation = { id: 99 }
+    vm.loadRecommendation()
+    expect(vm.recipeLoading).toBe(true)
+    expect(vm.meals).toEqual([])
+    expect(vm.recommendation).toBeNull()
+  })
+
+  it('请求成功后关闭 loading 并写入该视角食谱', async function () {
+    vm.currentView = 'family'
+    await vm.loadRecommendation()
+    expect(vm.recipeLoading).toBe(false)
+    expect(vm.recommendation.id).toBe(11)
+  })
+
+  it('请求失败进入失败态,保留重试入口所需数据位', async function () {
+    stubs.getDietRecommendation.mockImplementationOnce(function () {
+      return Promise.reject({ message: '网络异常' })
+    })
+    vm.currentView = 'family'
+    await vm.loadRecommendation()
+    expect(vm.recipeLoading).toBe(false)
+    expect(vm.recipeError).toContain('网络异常')
+    expect(typeof vm.retryRecommendation).toBe('function')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 3. 视角切换四处联动(AC-01/02/03/09)
+// ---------------------------------------------------------------------------
+
+describe('视角切换联动今日食谱', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('switchMember 触发 /today 且 view_key=member:<id>,仍保留 visibleCount=20', function () {
+    vm.visibleCount = 60
+    vm.switchMember({ familyMemberId: 42 })
+    expect(vm.currentView).toBe('personal')
+    expect(vm.currentMemberId).toBe(42)
+    expect(vm.visibleCount).toBe(20)
+    var payload = stubs.getDietRecommendation.mock.calls[0][0]
+    expect(payload.view_key).toBe('member:42')
+  })
+
+  it('switchView("family") 触发 /today 且 view_key=family', function () {
+    vm.currentMemberId = 42
+    vm.switchView('family')
+    expect(vm.currentMemberId).toBeNull()
+    var payload = stubs.getDietRecommendation.mock.calls[0][0]
+    expect(payload.view_key).toBe('family')
+  })
+
+  it('onRefresh 按当前视角重载 /today', function () {
+    vm.currentView = 'family'
+    vm.onRefresh()
+    expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
+      expect.objectContaining({ view_key: 'family' })
+    )
+  })
+
+  it('switchTab("recipe") 按当前视角重载 /today;其它 Tab 不重载', function () {
+    vm.currentView = 'family'
+    vm.switchTab('recipe')
+    expect(stubs.getDietRecommendation).toHaveBeenCalledTimes(1)
+    expect(stubs.getDietRecommendation.mock.calls[0][0].view_key).toBe('family')
+
+    stubs.getDietRecommendation.mockClear()
+    vm.switchTab('suitable')
+    expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
+  })
+
+  it('loadMembers 成功后补发一次 /today(onLoad 时序坑)', function () {
+    vm.currentView = 'personal'
+    vm.currentMemberId = null
+    global.uni.setStorageSync('token', 'test-token')
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: [{ familyMemberId: 8, nickname: '我', isSelf: true }] } })
+    })
+    vm.loadMembers()
+    expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
+      expect.objectContaining({ view_key: 'member:8' })
+    )
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 4. 生成载荷(AC-05/06/07/08)
+// ---------------------------------------------------------------------------
+
+describe('生成食谱载荷带 view_key 且不破坏共餐人多选', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('个人视角 generateRecipe 带 view_key=member:<id> 与 participant_member_ids=[id]', async function () {
+    vm.currentView = 'personal'
+    vm.currentMemberId = 42
+    await vm.generateRecipe()
+    var payload = stubs.generateDietRecommendation.mock.calls[0][0]
+    expect(payload.view_key).toBe('member:42')
+    expect(JSON.parse(payload.participant_member_ids)).toEqual([42])
+  })
+
+  it('家庭总览 confirmParticipants 带 view_key=family 与所选共餐人', async function () {
+    vm.currentView = 'family'
+    vm.memberList = [{ familyMemberId: 1, nickname: '成员1' }, { familyMemberId: 2, nickname: '成员2' }]
+    vm.generateRecipe()
+    await Promise.resolve()
+    await Promise.resolve()
+    // 先弹层,不直接生成
+    expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
+    await vm.confirmParticipants()
+    var payload = stubs.generateDietRecommendation.mock.calls[0][0]
+    expect(payload.view_key).toBe('family')
+    expect(JSON.parse(payload.participant_member_ids)).toEqual([1, 2])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 5. 模板契约(D1① / AC-12/19/33/34/35/38)
+// ---------------------------------------------------------------------------
+
+describe('模板契约:替换→删除 + 三态', function () {
+  var template
+  beforeAll(function () {
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+
+  it('模板不再出现「替换」/「好吃」/「跳过」', function () {
+    expect(template).not.toContain('替换')
+    expect(template).not.toContain('好吃')
+    expect(template).not.toContain('跳过')
+  })
+
+  it('模板含单一「🗑 删除」且由 dishActions 数据位驱动', function () {
+    expect(template).toContain('🗑 删除')
+    expect(template).toContain("dishActions === 'delete-only'")
+    expect(template).toContain('deleteDish(meal.name, dIndex)')
+  })
+
+  it('餐次头部含只读菜数状态、餐次级空态、整份空态、局部 loading、失败态', function () {
+    expect(template).toContain('dishStandard(meal)')
+    expect(template).toContain('本餐暂无菜品')
+    expect(template).toContain('今天的菜品都删完啦')
+    expect(template).toContain('正在加载今日食谱')
+    expect(template).toContain('今日食谱加载失败')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 6. 删除行为(AC-13/14/15/16/18/37)
+// ---------------------------------------------------------------------------
+
+describe('deleteDish 复用 /update 整份覆盖', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+    vm.currentView = 'family'
+    vm.recommendation = { id: 77, status: 'pending' }
+    vm.meals = [lunchWithDishes(), dinnerWithDishes()]
+    stubs.updateDietRecommendation.mockClear()
+  })
+
+  it('成功:移除该菜 + toast「已删除」+ 提交 {id, menu_json},不影响其它餐次', async function () {
+    await vm.deleteDish('午餐', 0)
+    expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
+    var payload = stubs.updateDietRecommendation.mock.calls[0][0]
+    expect(payload.id).toBe(77)
+    var menu = JSON.parse(payload.menu_json)
+    expect(menu.meals[0].dishes.length).toBe(4)
+    expect(menu.meals[0].dishes[0].type).toBe('dish')
+    expect(menu.meals[0].dishes[3].type).toBe('soup')
+    expect(menu.meals[1].dishes.length).toBe(5) // 晚餐不受影响
+    expect(vm.meals[0].dishes.length).toBe(4)   // 列表即时更新
+    expect(vm.meals[1].dishes.length).toBe(5)
+    expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ title: '已删除' }))
+  })
+
+  it('menu_json 保留 dish.type 且不混入 icon 展示字段', async function () {
+    vm.meals[0].icon = '☀️'
+    await vm.deleteDish('午餐', 0)
+    var text = stubs.updateDietRecommendation.mock.calls[0][0].menu_json
+    expect(text).not.toContain('"icon"')
+    expect(text).toContain('"type":"soup"')
+  })
+
+  it('失败:原菜品保留、deleting 复位可重试、toast 失败提示', async function () {
+    stubs.updateDietRecommendation.mockImplementationOnce(function () {
+      return Promise.reject({ message: '网络异常' })
+    })
+    await vm.deleteDish('午餐', 0)
+    expect(vm.meals[0].dishes.length).toBe(5)
+    expect(vm.deleting).toBe(false)
+    expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' }))
+  })
+
+  it('D1 可回退:dishActions 默认 delete-only,replaceDish 与 deleteDish 为独立方法', function () {
+    expect(vm.dishActions).toBe('delete-only')
+    expect(typeof vm.replaceDish).toBe('function')
+    expect(typeof vm.deleteDish).toBe('function')
+  })
+
+  it('防重复点击:deleting 期间不重复提交', async function () {
+    var release
+    stubs.updateDietRecommendation.mockImplementationOnce(function () {
+      return new Promise(function (resolve) { release = resolve })
+    })
+    vm.deleteDish('午餐', 0)
+    expect(vm.isDeleting('午餐', 0)).toBe(true)
+    vm.deleteDish('午餐', 1)
+    expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
+    release({ code: 200, message: 'success', data: null })
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(vm.deleting).toBe(false)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 7. D2 只读菜数状态(AC-17/25):不静默补菜
+// ---------------------------------------------------------------------------
+
+describe('dishStandard 只读状态(D2① 删除与补菜解耦)', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('4 菜 1 汤(N=3)显示达标', function () {
+    expect(vm.dishStandard(lunchWithDishes())).toContain('达标')
+  })
+
+  it('删掉 1 道菜后显示「当前 3 菜 1 汤 / 未达标准」', function () {
+    var meal = lunchWithDishes()
+    meal.dishes.splice(0, 1)
+    var text = vm.dishStandard(meal)
+    expect(text).toContain('当前 3 菜 1 汤')
+    expect(text).toContain('未达标准')
+  })
+
+  it('N=4 时 D(N)=5,5 菜 1 汤才达标', function () {
+    var meal = lunchWithDishes()
+    meal.participants = 4
+    expect(vm.dishStandard(meal)).toContain('未达标准')
+    meal.dishes.splice(0, 0, { name: '加菜', type: 'dish', calories: 0, ingredients: [], method: '' })
+    expect(vm.dishStandard(meal)).toContain('达标')
+  })
+
+  it('早餐不显示菜数状态;计算过程不触发任何补菜/重新生成请求', function () {
+    var breakfast = { type: 'breakfast', participants: 3, dishes: [{ name: '牛奶', type: 'dish' }] }
+    expect(vm.dishStandard(breakfast)).toBe('')
+    vm.dishStandard(lunchWithDishes())
+    expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
+    expect(stubs.regenerateDietRecommendation).not.toHaveBeenCalled()
+    expect(stubs.updateDietRecommendation).not.toHaveBeenCalled()
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 8. parseMenu 透传 dish.type / allMealsEmpty 整份空态(AC-25/35)
+// ---------------------------------------------------------------------------
+
+describe('parseMenu 与整份空态', function () {
+  var vm
+  beforeEach(function () {
+    vm = instantiate(loadRecommendation().component)
+  })
+
+  it('透传 dish.type(soup 可识别,存量无 type 默认 dish)', function () {
+    vm.parseMenu('{"meals":[{"type":"lunch","name":"午餐","participants":3,"dishes":[{"name":"番茄汤","type":"soup","calories":50},{"name":"青菜"}]}]}')
+    expect(vm.meals[0].dishes[0].type).toBe('soup')
+    expect(vm.meals[0].dishes[1].type).toBe('dish')
+  })
+
+  it('所有餐次菜数为 0 时 allMealsEmpty=true,可显示整份空态', function () {
+    vm.meals = [{ name: '午餐', type: 'lunch', dishes: [] }, { name: '晚餐', type: 'dinner', dishes: [] }]
+    expect(vm.allMealsEmpty).toBe(true)
+    vm.meals[0].dishes.push({ name: '红烧肉', type: 'dish' })
+    expect(vm.allMealsEmpty).toBe(false)
+  })
+})

+ 287 - 39
cfc-frontend/pages/diet/recommendation.vue

@@ -75,10 +75,10 @@
           </view>
         </view>
 
-        <!-- 家庭总览(取家庭成员该项食材推荐指数的最小值) -->
+        <!-- 家庭总览(有一人戒备取全员最低值,全员适合取平均值) -->
         <view class="family-overview" v-if="currentView === 'family' && familyOverview && familyOverview.length > 0">
           <view class="overview-title">
-            <text>🏠 家庭推荐概况(共 {{ familyMembers.length }} 人 · 取家庭最保守值)</text>
+            <text>🏠 家庭推荐概况(共 {{ familyMembers.length }} 人 · 一人戒备取最低,全员适合取平均)</text>
           </view>
           <view class="overview-item" v-for="(item, idx) in familyOverview" :key="idx">
             <view class="overview-header">
@@ -156,8 +156,23 @@
       <!-- 今日食谱区(recipe Tab 内容) -->
       <view v-if="currentTab === 'recipe'">
 
-      <!-- 无推荐状态 -->
-      <view class="empty-state" v-if="!recommendation">
+      <!-- 局部加载态:切换视角 / 刷新期间先清空旧数据,不残留上一视角食谱(AC-36,不整页遮罩) -->
+      <view class="recipe-loading" v-if="recipeLoading">
+        <text class="recipe-loading-text">正在加载今日食谱...</text>
+      </view>
+
+      <!-- 失败态:接口异常 → 明确提示 + 重试入口,文案与空态区分(AC-16 / AC-38) -->
+      <view class="empty-state" v-else-if="recipeError">
+        <view class="empty-icon-wrap"><text class="empty-icon">⚠️</text></view>
+        <text class="empty-title">今日食谱加载失败</text>
+        <text class="empty-sub">{{ recipeError }}</text>
+        <view class="generate-btn" @tap="retryRecommendation">
+          <text>点击重试</text>
+        </view>
+      </view>
+
+      <!-- 无推荐状态 / 视角级空态(AC-04;不回退展示其它视角食谱) -->
+      <view class="empty-state" v-else-if="!recommendation">
         <view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
         <text class="empty-title">今日菜单空着</text>
         <text class="empty-sub">点击生成今日三餐食谱</text>
@@ -166,6 +181,16 @@
         </view>
       </view>
 
+      <!-- 整份空态:所有餐次菜数为 0(删除后可能出现,AC-35) -->
+      <view class="empty-state" v-else-if="allMealsEmpty">
+        <view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
+        <text class="empty-title">今天的菜品都删完啦</text>
+        <text class="empty-sub">点击重新生成今日三餐食谱</text>
+        <view class="generate-btn" @tap="regenerate">
+          <text>重新生成</text>
+        </view>
+      </view>
+
       <!-- 有推荐状态 -->
       <view v-else>
         <!-- 日期和状态 -->
@@ -187,9 +212,16 @@
             </view>
             <text class="meal-name">{{ meal.name }}</text>
             <text class="meal-count">{{ meal.participants }} 人份</text>
+            <!-- D2① 只读状态提示:当前 N 菜 1 汤 / 未达标准,不静默补菜 -->
+            <text class="meal-standard" v-if="dishStandard(meal)">{{ dishStandard(meal) }}</text>
           </view>
-          
+
           <view class="dish-list">
+            <!-- 餐次级空态:某餐菜数删至 0(AC-34),提供「重新生成」入口 -->
+            <view class="meal-empty" v-if="meal.dishes.length === 0">
+              <text class="meal-empty-text">本餐暂无菜品</text>
+              <view class="meal-empty-btn" @tap="regenerate"><text>重新生成</text></view>
+            </view>
             <view class="dish-item" v-for="(dish, dIndex) in meal.dishes" :key="dIndex">
               <view class="dish-header">
                 <text class="dish-name">{{ dish.name }}</text>
@@ -203,10 +235,11 @@
               <view class="dish-method">
                 <text class="method-text">🍳 {{ dish.method }}</text>
               </view>
-              <view class="dish-actions">
-                <view class="action-btn like-btn" @tap="likeDish(meal.name, dIndex)">👍 好吃</view>
-                <view class="action-btn swap-btn" @tap="swapDish(meal.name, dIndex)">🔄 替换</view>
-                <view class="action-btn skip-btn" @tap="skipDish(meal.name, dIndex)">✕ 跳过</view>
+              <!-- D1①:收敛为单一「🗑 删除」;按钮组合由 dishActions 数据位驱动,便于回退 -->
+              <view class="dish-actions" v-if="dishActions === 'delete-only'">
+                <view class="action-btn delete-btn"
+                  :class="{'action-btn-disabled': isDeleting(meal.name, dIndex)}"
+                  @tap="deleteDish(meal.name, dIndex)">🗑 删除</view>
               </view>
             </view>
           </view>
@@ -289,7 +322,7 @@
 </template>
 
 <script>
-import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation } from '@/utils/api.js'
+import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation, updateDietRecommendation } from '@/utils/api.js'
 import config from '@/config.js'
 import MemberSelector from '@/components/MemberSelector.vue'
 import { memberIdOf } from '@/utils/memberNormalize.js'
@@ -324,7 +357,14 @@ export default {
       refreshing: false,
       // ===== 共餐人选择(需求7:家庭总览生成食谱先选共餐人)=====
       showParticipantPicker: false,
-      pendingParticipantIds: []
+      pendingParticipantIds: [],
+      // ===== 今日食谱视角隔离 / 删除交互 / 空·加载·失败三态(本轮增量)=====
+      recipeLoading: false,
+      recipeError: '',
+      deleting: false,
+      deletingKey: '',
+      // D1① 可回退数据位:'delete-only'(当前)/ 'replace-and-delete'(备选,改此值 + 模板分支即可)
+      dishActions: 'delete-only'
     }
   },
   computed: {
@@ -409,6 +449,15 @@ export default {
     pagedFoods: function() {
       return this.filteredFoods.slice(0, this.visibleCount)
     },
+    // 整份食谱空态:所有餐次菜数为 0(AC-35)
+    allMealsEmpty: function() {
+      var meals = this.meals || []
+      if (meals.length === 0) return true
+      for (var i = 0; i < meals.length; i++) {
+        if (meals[i].dishes && meals[i].dishes.length > 0) return false
+      }
+      return true
+    },
     // 共餐人选择弹层列表(原始 memberList 归一化为 {id, nickname, selected},避免模板内方法调用)
     pendingParticipantOptions: function() {
       var self = this
@@ -438,10 +487,27 @@ export default {
     goBack: function() {
       uni.navigateBack()
     },
+    // 按当前视角加载今日食谱:view_key 同源、局部 loading 先清空防串数据(AC-01/03/09/36)
     loadRecommendation: function() {
       var self = this
+      var viewKey = self.buildViewKey()
+      self.recipeError = ''
+      // 视角尚未就绪(首次 onLoad 时 loadRecommendation 先于异步 loadMembers,personal 且无 currentMemberId):
+      // 不发请求、清空残留,由 loadMembers 成功回调补发一次
+      if (!viewKey) {
+        self.recipeLoading = false
+        self.recommendation = null
+        self.meals = []
+        self.summary = {}
+        return Promise.resolve()
+      }
       var today = self.formatDate(new Date())
-      getDietRecommendation({ date: today }).then(function(res) {
+      self.recipeLoading = true
+      self.recommendation = null
+      self.meals = []
+      self.summary = {}
+      return getDietRecommendation({ date: today, view_key: viewKey }).then(function(res) {
+        self.recipeLoading = false
         var d = res && res.data
         if (d && d.id) {
           self.recommendation = d
@@ -453,8 +519,31 @@ export default {
           self.meals = []
           self.summary = {}
         }
+      }).catch(function(err) {
+        self.recipeLoading = false
+        self.recommendation = null
+        self.meals = []
+        self.summary = {}
+        self.recipeError = (err && err.message) || '加载失败,请重试'
       })
     },
+    // 视角键生成(K1 可回退的唯一落点):家庭总览固定 'family';个人/成员 'member:<id>';
+    // loadMembers 未完成、memberList 为空且无 currentMemberId 时返回 null 且不发请求。
+    // 后端把 view_key 当不透明字符串,语义全在此方法一处。
+    buildViewKey: function() {
+      if (this.currentView === 'family') return 'family'
+      if (this.currentView === 'personal') {
+        if (this.currentMemberId !== null && this.currentMemberId !== undefined) {
+          return 'member:' + this.currentMemberId
+        }
+        return null
+      }
+      return null
+    },
+    // 失败态重试:按当前视角重新加载,不重置用户当前视角
+    retryRecommendation: function() {
+      this.loadRecommendation()
+    },
     parseMenu: function(menuJson) {
       try {
         var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
@@ -476,6 +565,8 @@ export default {
               })
               return {
                 name: dish.name,
+                // 汤/菜可识别:dish.type = dish|soup,存量无 type 时默认 'dish'(AC-25)
+                type: dish.type || 'dish',
                 calories: dish.calories || (dish.nutrition && dish.nutrition.calories) || 0,
                 ingredients: ing,
                 method: dish.method || dish.cooking_method || ''
@@ -508,7 +599,8 @@ export default {
       return generateDietRecommendation({
         date: self.formatDate(new Date()),
         meal_type: 'all',
-        participant_member_ids: JSON.stringify(participantIds)
+        participant_member_ids: JSON.stringify(participantIds),
+        view_key: self.buildViewKey()
       }).then(function(res) {
         self.loading = false
         uni.hideLoading()
@@ -559,7 +651,8 @@ export default {
       generateDietRecommendation({
         date: self.formatDate(new Date()),
         meal_type: 'all',
-        participant_member_ids: JSON.stringify(self.pendingParticipantIds || [])
+        participant_member_ids: JSON.stringify(self.pendingParticipantIds || []),
+        view_key: self.buildViewKey()
       }).then(function(res) {
         self.loading = false
         if (res && res.data && res.data.id) {
@@ -578,14 +671,98 @@ export default {
       this.showParticipantPicker = false
       this.pendingParticipantIds = []
     },
-    likeDish: function(mealName, dishIndex) {
-      uni.showToast({ title: '已点赞', icon: 'success' })
+    // D2① 只读菜数状态:当前 X 菜 Y 汤 / 未达标准;与删除逻辑解耦,绝不触发补菜请求
+    dishStandard: function(meal) {
+      if (!meal || (meal.type !== 'lunch' && meal.type !== 'dinner')) return ''
+      var dishes = meal.dishes || []
+      var mainCount = 0
+      var soupCount = 0
+      for (var i = 0; i < dishes.length; i++) {
+        if (dishes[i].type === 'soup') soupCount += 1
+        else mainCount += 1
+      }
+      var n = meal.participants || 1
+      var expected = 4 + Math.max(0, n - 3)
+      var reached = mainCount >= expected && soupCount >= 1
+      var text = '当前 ' + mainCount + ' 菜 ' + soupCount + ' 汤'
+      return reached ? (text + ' · 达标') : (text + ' / 未达标准')
+    },
+    // 防重复点击:删除中的那一道菜按钮禁用(AC-37)
+    isDeleting: function(mealName, dishIndex) {
+      return this.deleting && this.deletingKey === (mealName + ':' + dishIndex)
     },
-    swapDish: function(mealName, dishIndex) {
-      uni.showToast({ title: '已替换菜品', icon: 'success' })
+    // 从 this.meals 组装「移除目标菜」后的整份 menu_json(不含 icon 等纯展示字段,避免结构漂移)
+    buildMenuPayload: function(mealName, dishIndex) {
+      var payloadMeals = []
+      var meals = this.meals || []
+      for (var i = 0; i < meals.length; i++) {
+        var meal = meals[i]
+        var dishes = []
+        var src = meal.dishes || []
+        for (var j = 0; j < src.length; j++) {
+          if (meal.name === mealName && j === dishIndex) continue
+          var d = src[j]
+          var ings = []
+          var srcIng = d.ingredients || []
+          for (var k = 0; k < srcIng.length; k++) {
+            ings.push({ name: srcIng[k].name, amount: srcIng[k].amount || 0 })
+          }
+          dishes.push({
+            name: d.name,
+            type: d.type || 'dish',
+            calories: d.calories || 0,
+            ingredients: ings,
+            method: d.method || ''
+          })
+        }
+        payloadMeals.push({
+          type: meal.type,
+          name: meal.name,
+          participants: meal.participants || 1,
+          dishes: dishes
+        })
+      }
+      return JSON.stringify({ meals: payloadMeals })
+    },
+    // D1① 单一主操作「🗑 删除」:复用 POST /api/diet/recommendation/update 整份覆盖。
+    // 删除与补菜完全解耦(不调任何补菜 / 重新生成接口);失败保留原数据可重试(AC-13/15/16/18)
+    deleteDish: function(mealName, dishIndex) {
+      var self = this
+      if (self.deleting) return
+      if (!self.recommendation || !self.recommendation.id) return
+      var targetMeal = null
+      for (var i = 0; i < (self.meals || []).length; i++) {
+        if (self.meals[i].name === mealName) {
+          targetMeal = self.meals[i]
+          break
+        }
+      }
+      if (!targetMeal || !targetMeal.dishes || dishIndex < 0 || dishIndex >= targetMeal.dishes.length) return
+      self.deleting = true
+      self.deletingKey = mealName + ':' + dishIndex
+      uni.showLoading({ title: '删除中...' })
+      var payload = self.buildMenuPayload(mealName, dishIndex)
+      return updateDietRecommendation({ id: self.recommendation.id, menu_json: payload }).then(function(res) {
+        self.deleting = false
+        self.deletingKey = ''
+        uni.hideLoading()
+        if (!res) {
+          uni.showToast({ title: '删除失败,请重试', icon: 'none' })
+          return
+        }
+        targetMeal.dishes.splice(dishIndex, 1)
+        uni.showToast({ title: '已删除', icon: 'success' })
+      }).catch(function(err) {
+        self.deleting = false
+        self.deletingKey = ''
+        uni.hideLoading()
+        uni.showToast({ title: (err && err.message) || '删除失败,请重试', icon: 'none' })
+      })
     },
-    skipDish: function(mealName, dishIndex) {
-      uni.showToast({ title: '已跳过', icon: 'none' })
+    // D1 可回退入口:当前 dishActions='delete-only' 时模板不渲染,与 deleteDish 完全独立、互不引用。
+    // 若产品改选「双按钮」,只需把 dishActions 切为 'replace-and-delete' 并在模板补分支。
+    replaceDish: function(mealName, dishIndex) {
+      uni.showToast({ title: '该功能已下线,请用「删除」或「重新生成」', icon: 'none' })
     },
     regenerate: function() {
       var self = this
@@ -642,6 +819,8 @@ export default {
               var defaultMember = self.getSelfRow() || self.memberList[0]
               self.currentMemberId = memberIdOf(defaultMember)
             }
+            // onLoad 先 loadRecommendation 后异步 loadMembers:此时个人视角键才就绪,补发一次(AC-01/10)
+            self.loadRecommendation()
           }
         }
       })
@@ -697,19 +876,23 @@ export default {
             self.familyMembers = Object.keys(res.data.data.byMember || {}).map(function(key) {
               return { userId: key, nickname: res.data.data.memberNames[key] || '成员' }
             })
-            // 优先使用后端持久化总览(overview,minIndexScore = 家庭成员该项食材最新推荐指数的最小值)
+            // 优先使用后端持久化总览(overview):overviewScore = 分段聚合展示值
+            // (有成员<0取全员最低值、全员>=0取平均值);旧版后端无该字段时回退 minIndexScore
             if (res.data.data.overview && res.data.data.overview.length > 0) {
               self.familyOverview = res.data.data.overview.map(function(o) {
-                var min = o.minIndexScore
+                var score = (o.overviewScore !== undefined && o.overviewScore !== null)
+                  ? o.overviewScore : o.minIndexScore
                 return {
                   id: o.foodId,
                   name: o.foodName,
                   category: o.category,
-                  score: min,
-                  scoreClass: self.getScoreClass(min),
-                  color: self.getScoreClass(min),
-                  statusText: self.getStatusText(min),
-                  status: min >= 0 ? 'suitable' : 'unsuitable',
+                  score: score,
+                  scoreClass: self.getScoreClass(score),
+                  color: self.getScoreClass(score),
+                  statusText: self.getStatusText(score),
+                  status: score >= 0 ? 'suitable' : 'unsuitable',
+                  aggregateRule: o.aggregateRule || '',
+                  minScore: o.minIndexScore,
                   memberCount: o.memberCount || 0,
                   memberScores: (o.memberScores || []).map(function(s) {
                     return { name: s.nickname || '', score: s.score }
@@ -717,7 +900,7 @@ export default {
                 }
               })
             } else {
-              // 后端未返回 overview 时的降级路径:前端按 min 计算(比 avg 更安全)
+              // 后端未返回 overview 时的降级路径:前端按同一分段聚合口径计算
               self.buildFamilyOverview(res.data.data.total || res.data.data.totalList, res.data.data.byMember, res.data.data.memberNames)
             }
           }
@@ -746,28 +929,36 @@ export default {
           }
         }
 
-        // 降级路径:取家庭成员该项食材的最小值(最保守值),比 avg 更安全
+        // 降级路径:与后端同口径 —— 有人 < 0 取全员最低值,全员 >= 0 取平均值(四舍五入)
         if (memberScores.length > 0) {
           var minScore = memberScores[0].score
-          for (var mi = 1; mi < memberScores.length; mi++) {
-            if (memberScores[mi].score < minScore) minScore = memberScores[mi].score
+          var sum = 0
+          var hasNegative = false
+          for (var mi = 0; mi < memberScores.length; mi++) {
+            var s = memberScores[mi].score
+            if (s < minScore) minScore = s
+            sum += s
+            if (s < 0) hasNegative = true
           }
+          var score = hasNegative ? minScore : Math.round(sum / memberScores.length)
           overview.push({
             id: food.id,
             name: food.name,
             category: food.category,
-            score: minScore,
-            scoreClass: self.getScoreClass(minScore),
-            color: self.getScoreClass(minScore),
-            statusText: self.getStatusText(minScore),
-            status: minScore >= 0 ? 'suitable' : 'unsuitable',
+            score: score,
+            scoreClass: self.getScoreClass(score),
+            color: self.getScoreClass(score),
+            statusText: self.getStatusText(score),
+            status: score >= 0 ? 'suitable' : 'unsuitable',
+            aggregateRule: hasNegative ? 'MIN_NEGATIVE' : 'AVG_POSITIVE',
+            minScore: minScore,
             memberCount: memberScores.length,
             memberScores: memberScores
           })
         }
       }
 
-      // 按最小推荐值排序(从高到低)
+      // 按总览展示值排序(从高到低)
       overview.sort(function(a, b) {
         return b.score - a.score
       })
@@ -822,6 +1013,9 @@ export default {
       if (tab !== 'recipe') {
         this.filterType = tab
         this.sortType = tab === 'unsuitable' ? 'asc' : 'desc'
+      } else {
+        // 切回「今日食谱」按当前视角重载,避免展示切换前残留(AC-09)
+        this.loadRecommendation()
       }
     },
     loadMore: function() {
@@ -838,6 +1032,8 @@ export default {
         this.currentMemberId = null
         this.memberSelectLocked = true
       }
+      // 视角切换后按新 buildViewKey() 重载今日食谱(AC-03)
+      this.loadRecommendation()
     },
     // 取「本人」行(isSelf):后端已按 user_id 折叠,此处仅取第一行 isSelf
     getSelfRow: function() {
@@ -853,12 +1049,16 @@ export default {
       this.currentView = 'personal'
       this.visibleCount = 20
       this.loadFoods()
+      // 成员切换联动今日食谱(AC-01/02),仍保留 loadFoods + visibleCount=20 既有行为
+      this.loadRecommendation()
     },
     onRefresh: function() {
       var self = this
       self.refreshing = true
       self.loadFoods()
       self.loadFamilyData()
+      // 下拉刷新按当前视角重载今日食谱(AC-09)
+      self.loadRecommendation()
       setTimeout(function() {
         self.refreshing = false
       }, 500)
@@ -869,7 +1069,11 @@ export default {
 
 <style scoped lang="scss">
 .recommendation-page {
-  min-height: 100vh;
+  /* 翻页失效根因修复:父容器必须是「确定高度」而不是由内容撑开的 min-height。
+     原 min-height:100vh 下 scroll-view 会随内容一起长高,退化为页面级滚动,
+     @scrolltolower 永不触发 → visibleCount 卡在 20 → 下滑不追加食材。
+     页面内内容全部在 scroll-view 内部,弹层为 position:fixed,故固定 100vh 安全。 */
+  height: 100vh;
   background-color: $app-bg;
   display: flex;
   flex-direction: column;
@@ -878,7 +1082,12 @@ export default {
   width: 80rpx;
 }
 .content {
+  /* 保留既有 flex 布局;height:0 给出确定基准,min-height:0 阻止 min-content 把
+     滚动容器撑高。配合父级 height:100vh,flex:1 才能算出确定高度,让
+     scroll-y 形成内部滚动视口并派发 @scrolltolower(H5 / 小程序通用)。 */
   flex: 1;
+  height: 0;
+  min-height: 0;
   padding: $uni-space-page;
   box-sizing: border-box;
   width: 100%;
@@ -952,7 +1161,7 @@ export default {
   overflow: hidden;
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
 }
-.meal-header { display: flex; align-items: center; padding: 24rpx $uni-space-page; border-bottom: 1rpx solid #FDE8D0; }
+.meal-header { display: flex; flex-wrap: wrap; align-items: center; padding: 24rpx $uni-space-page; border-bottom: 1rpx solid #FDE8D0; }
 .meal-icon-wrap { width: 64rpx; height: 64rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
 .meal-icon-breakfast { background: linear-gradient(135deg, $dim-body-light, #FFD48A); }
 .meal-icon-lunch     { background: linear-gradient(135deg, $status-warning, $accent-amber-light); }
@@ -1005,9 +1214,48 @@ export default {
 }
 .dish-actions { display: flex; gap: 16rpx; margin-top: 8rpx; }
 .action-btn { padding: 12rpx 26rpx; border-radius: 999rpx; font-size: calc(24rpx * var(--font-scale, 1)); }
+/* 既有按钮样式保留备用,便于 D1 回退到双按钮组合(当前 dishActions='delete-only' 不渲染) */
 .like-btn { background: $mat-green-tint; color: $mat-green; }
 .swap-btn { background: #E8F1FB; color: $mat-blue-base; }
 .skip-btn { background: $fortune-0-tint; color: $mat-red; }
+/* 本轮增量:删除主操作 / 防重复点击 / 菜数只读状态 / 空·加载态 */
+.delete-btn { background: $fortune-0-tint; color: $mat-red; }
+.action-btn-disabled { opacity: 0.5; }
+.meal-standard {
+  flex-basis: 100%;
+  text-align: right;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-top: 8rpx;
+}
+.meal-empty {
+  padding: 40rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.meal-empty-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-bottom: 20rpx;
+}
+.meal-empty-btn {
+  padding: 12rpx 40rpx;
+  border-radius: 999rpx;
+  background: $tint-warm-soft;
+  color: $brand-primary-deep;
+  font-size: calc(24rpx * var(--font-scale, 1));
+}
+.recipe-loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 60rpx 0;
+}
+.recipe-loading-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
 .nutrition-grid {
   display: flex;
   justify-content: space-around;

+ 14 - 2
cfc-langgraph/app/api/meal.py

@@ -33,11 +33,12 @@ MENU_GENERATE_TEMPLATE = """请为{participant_count}人生成{date}的一日三
 {{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "做法一句话", "nutrition": {{"calories": 200}}}}]}}]}}
 
 要求:
-1. 早/午/晚各至少1-2道菜
+1. 午餐和晚餐按上方约束给出的菜数配菜(D(N)=4+max(0,N-3) 道菜 + 1 道汤,汤恒 1 且不计入菜数、只加菜不加汤),早餐维持 1-2 道
 2. 食材用量按{participant_count}人份计算
 3. 营养均衡,考虑健康目标
 4. 只用提供的食材
-5. 只返回 JSON,不要其他文字"""
+5. 每道菜用 type 字段标注 dish(菜)或 soup(汤),汤恰好 1 道且排在最后
+6. 只返回 JSON,不要其他文字"""
 
 
 @router.post("/food/recognize", response_model=FoodRecognizeResponse)
@@ -120,6 +121,17 @@ async def generate_menu(req: MenuGenerateRequest):
             constraints.append(f"菜系偏好: {req.cuisine_pref}")
         if req.spice_level is not None:
             constraints.append(f"辣度: {req.spice_level}/5")
+
+        # 菜数要求(CR-C / R-05):注入既有 {constraints_text} 占位符,不新增占位符(F-18),
+        # 因此 DB 模板(get_prompt)与本地模板两个来源同时生效;不依赖 prompt 的正确性,
+        # 菜数硬保证由 Java DietRecommendationService.normalizeMenu 兜底。
+        # Java AiGateway 传 dish_count = D(N);旧调用方不传时按 participant_count 现算,行为一致。
+        effective_dish_count = req.dish_count if req.dish_count is not None else 4 + max(0, participant_count - 3)
+        constraints.append(
+            f"菜数硬要求: 午餐和晚餐各需 {effective_dish_count} 道菜 + 1 道汤"
+            f"(D(N)=4+max(0,N-3),汤恒 1 且不计入菜数、只加菜不加汤;"
+            f"汤标 type=soup、菜标 type=dish,汤恰好 1 道且排在最后);早餐维持 1-2 道。"
+        )
         constraints_text = "\n".join(constraints) if constraints else "无特殊限制"
 
         role = await get_prompt("menu_generate_role") or "你是一个专业营养师和厨师,擅长根据食材和健康目标设计食谱。"

+ 3 - 0
cfc-langgraph/app/models/meal.py

@@ -28,6 +28,9 @@ class MenuGenerateRequest(BaseModel):
     health_goals: Optional[str] = None
     spice_level: Optional[int] = None
     cuisine_pref: Optional[str] = None
+    # CR-C / AC-23:Java AiGateway 下发的午/晚餐标准菜数 D(N)。
+    # 默认 None 让旧调用方 100% 兼容;菜数硬保证在 Java normalizeMenu,此处仅软引导 prompt。
+    dish_count: Optional[int] = None
 
 
 class MenuIngredient(BaseModel):

+ 15 - 6
docs/superpowers/api/API_REFERENCE.md

@@ -717,8 +717,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 |------|------|
 | `POST /api/diet/preferences/current-member` | 当前成员饮食偏好 |
 | `POST /api/diet/preferences/save` | 保存饮食偏好 |
-| `POST /api/diet/recommendation/today` | 今日推荐(返回 `data.id / data.menu / data.nutritionSummary / data.status`) |
-| `POST /api/diet/recommendation/generate` | 生成推荐(body: `date, meal_type, selected_foods JSON string`;返回 `data.id / data.menu / data.nutritionSummary`) |
+| `POST /api/diet/recommendation/today` | 今日推荐(返回 `data.id / data.menu / data.nutritionSummary / data.status`);**可选 `view_key`**(`member:<id>` / `family`,迁移466 视角隔离),传入时追加 `.eq(view_key)` 并按 `version,id` 倒序取最新、不回退展示其它视角;不传则完全保持原查询(老客户端 / 存量 `NULL` 行兼容);`date` 仍必填(空→`Result.error("date不能为空")`) |
+| `POST /api/diet/recommendation/generate` | 生成推荐(body: `date, meal_type, selected_foods JSON string`);**主读 `participant_member_ids`**(JSON 字符串或原生数组均可,N = 本次所选共餐人数,`meal_config` 仅兜底)、可选 **`view_key`** 参与幂等键(`family_id + date + meal_type + view_key`);返回 `data.id / data.menu / data.nutritionSummary`(可选 `participantCount` / `dishCount`) |
 | `POST /api/diet/recommendation/complete` | 完成推荐 |
 | `POST /api/diet/record/save` | 保存饮食记录 |
 | `POST /api/diet/record/daily` | 每日记录 |
@@ -728,6 +728,13 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/diet/meals/config` | 餐食配置(body: `date_type` + `meal_type`,返回 `data.participantMemberIds` 等) |
 | `POST /api/diet/meals/config/save` | 保存餐食配置,**批量格式**(推荐,单次调用):body `{date_type:"weekday", configs:{breakfast:"[1,2]", lunch:"[]", dinner:"[3]"}}`(各餐次值为成员ID数组的 JSON 字符串);亦兼容**单条格式**:body `{date_type, meal_type, participant_member_ids, notes?}`。前端 `saveMealConfig` 位于 `cfc-frontend/utils/api.js`(2772行) |
 
+> **本轮行为补注(2026-11-10,迁移466;不新增任何端点)**:
+> - `/api/diet/recommendation/update` 复用为「删除菜品」(前端 `updateDietRecommendation` 提交移除该菜后的整份 `menu_json`)。**不再把 `status` 置为 `adjusted`**、保持原值,避免删除后 `/today` 白名单(`pending/accepted/completed`)查不到导致整页回空态;新增 `menu_json` 合法 JSON / 含 `meals` 数组校验(非法 → 400 `menu_json格式非法`)。删除与补菜解耦,此路径不触发补菜。
+> - `/api/diet/recommendation/regenerate` 改为真正走 AI / 降级 / `normalizeMenu` 菜数归一化后**覆盖同 `view_key` 记录**(`version = old+1`、`status` 回 `pending`、不再 insert `menu_json="{}"` 的历史行)。
+> - 午 / 晚餐菜数 `D(N)=4+max(0,N-3)` 道菜 + 恒 1 道汤,靠 `dish.type = dish|soup` 区分(不靠菜名猜);AI 少给菜由 Java 补齐、不重试,早餐维持现状。
+> - `/api/meal/replace` 语义为「换一个食材」(见 4.59.6.3),本轮**明确不复用**;删除菜品不新增端点。
+> - 上述端点统一 `@PostMapping` + `Result<T>`;接口查重:4.11 / 4.63.3 / 4.59.6.3 均无「删除菜品」端点,无重复登记。
+
 ### 4.12 内容(`/api/articles`, `/api/content/*`)
 
 | 路径 | 说明 |
@@ -2894,13 +2901,15 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 | `POST /api/food/recommendation/trend` | `{memberId?}` | 推荐趋势 | 食材推荐多期趋势 |
 
 > 报告确认/代确认后 `foodRecommendService.recalculateForUser(subjectId)` 自动刷新食材推荐指数(见 4.59.2 `/report/confirm` 与 4.59.3 `/proxy-confirm`)。
+>
+> **家庭总览 `data.overview`(迁移465 起)**:`/api/food/recommendation/by-family` 与 `/api/health/foods/by-family` 均返回 `overview` 数组,元素含 `foodId / foodName / category / overviewScore / aggregateRule / minIndexScore / memberCount / memberScores / calculatedAt`。展示值以 **`overviewScore`** 为准:同一食材只要有一名成员指数 `< 0` → 取全员最低值(`aggregateRule=MIN_NEGATIVE`,风险优先);全员 `>= 0` → 取平均值(四舍五入取整,0 分计入平均,`AVG_POSITIVE`)。`minIndexScore` 始终是真实成员最小值,保留给旧客户端与诊断,语义不变;迁移前的历史行 `overview_score` 为 NULL 时,读取侧自动按新口径重算补齐,接口不会返回 null。
 
 #### 4.59.6.3 餐食推荐(`MealRecommendController` `/api/meal`)
 
 | 路径 | 请求 | 响应 `data` | 说明 |
 |------|------|-------------|------|
 | `POST /api/meal/recommend` | `{...}` | 推荐菜单 | 基于报告/偏好推荐餐食 |
-| `POST /api/meal/replace` | `{...}` | 替换后菜单 | 换一道菜 |
+| `POST /api/meal/replace` | `{foodId}` + `@RequestAttribute userId` | `Result<Food>`(**换一个食材**) | ⚠️ **文档与实现偏差(F-16)**:旧描述「替换后菜单 / 换一道菜」与实现不符——实为 `MealRecommendController#replace` 返回单个 `Food`,**完全不操作 `diet_recommendations.menu_json`**。因此「删除菜品」不复用本端点,改复用 `POST /api/diet/recommendation/update`(见 4.11 行为补注) |
 | `POST /api/meal/log` | `{...}` | 保存结果 | 记录食用 |
 | `POST /api/meal/logs` | `{memberId?, date?}` | 记录列表 | 食用记录列表 |
 | `POST /api/meal/nutrition-summary` | `{memberId?, date?}` | 营养汇总 | 营养摄入汇总 |
@@ -3251,8 +3260,8 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 ### 4.61.8 饮食 / AI / 舌诊情绪 / 睡眠(SC-C-12 / C-13 / C-21 / C-26)
 | 路径 | 请求 | 响应 `data` | 说明 |
 |------|------|-------------|------|
-| `POST /api/diet/recommendation/today` | `{date}`(**必填**,空→`Result.error("date不能为空")`) | 今日食谱 | 饮食推荐 |
-| `POST /api/diet/recommendation/generate` | `{date, ...}` | 食谱 | 生成/重新生成 |
+| `POST /api/diet/recommendation/today` | `{date}`(**必填**,空→`Result.error("date不能为空")`);可选 `view_key` | 今日食谱(按视角隔离) | 饮食推荐 |
+| `POST /api/diet/recommendation/generate` | `{date, ...}`;主读 `participant_member_ids`、可选 `view_key` | 食谱 | 生成/重新生成(午晚餐 `D(N)=4+max(0,N-3)` 菜 + 1 汤) |
 | `POST /api/diet/recommendation/complete` | `{date, ...}` | `Boolean` | 完成标记 |
 | `POST /api/health/meal/daily` / `/weekly` / `/list` | `{memberId?, date?}` | 餐次数据 | 餐次记录 |
 | `POST /api/ai/chat/send` | `{query, conversationId?}` | `Map`(`answer`/`conversationId`/...) | AI 对话(会员门禁 `checkGate`) |
@@ -3454,7 +3463,7 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 | `POST /api/food/recommendation/index`、`/caution`、`/members`、`/trend`、`/by-family` | 食谱推荐 / 忌口(caution)/ 家庭成员 / 趋势(SC-P-28) |
 | `POST /api/diet/preferences/current-member`、`/save` | 忌口参数(`DietPreferencesController`,命名回填:候选 `UserFoodPreferenceController` 不存在) |
 | `POST /api/diet/ingredients/suggest`、`/search`、`/add`、`/remove` | 食材筛选(SC-P-28) |
-| `POST /api/meal/replace`;`POST /api/diet/recommendation/update`、`/regenerate`、`/complete` | 食谱调整(SC-P-29「调整」);**「收藏」无端点 → 缺口 G-1** |
+| `POST /api/meal/replace`;`POST /api/diet/recommendation/update`、`/regenerate`、`/complete` | 食谱调整(SC-P-29「调整」);**删除菜品复用 `/update`**(`/api/meal/replace` 实为换食材、不复用,见 4.59.6.3 偏差标注;`/update` 不再变更 `status`、`/regenerate` 真正重新生成,见 4.11 行为补注);**「收藏」无端点 → 缺口 G-1** |
 | `POST /api/nutrition/profile/get`、`/save` | 精准营养目标(SC-P-69,`UserNutritionProfileController`) |
 | `POST /api/diet/recommendation/generate` | 按营养目标生成/对齐(SC-P-30) |
 | `POST /api/health/nutrition/profile`、`/deficiency`、`/deficiency-summary`、`/trend` | 营养报告段(SC-P-69) |

File diff suppressed because it is too large
+ 67 - 0
docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md


+ 439 - 0
docs/需求分析/2026-11-10-食材推荐食谱需求变更.md

@@ -0,0 +1,439 @@
+# 食材推荐 / 今日食谱模块增量需求变更 PRD(2026-11-10)
+
+> **版本:** v1.1(产品经理产出,供架构师做影响面分析与任务分解)
+> **修订记录:** v1.1(2026-11-10)——依据架构师《影响面与任务分解》§0.2 复核结果订正 4 处代码事实(F-13 / F-16 / F-17 / F-20)、强化 R-02 / R-05、回写 D1~D6 裁定状态(见 6.1);并按主理人指令完成接口查重结论(`/api/meal/replace` 不复用、删除复用 `/update`)、8.3 测试文件口径与新增 `DietRecommendationServiceTest`、R-05「菜数硬保证落 Java `normalizeMenu()`、prompt 仅软引导」等事实订正。**2026-11-10 用户确认「按推荐来」:D1 / D2 / 隔离粒度 / 存量回填 / R-03 共 5 项全部按推荐方案裁定,G-6 门禁已关闭、T1~T5 可开工。38 条 AC 编号与验收语义、需求变更清单、菜数规则表 / 公式 / 示例表、用户故事一律未改动**(仅 AC-19 的「待确认」状态字样随裁定更新);技术方案以架构师文档为准,本 PRD 仍是产品规则的单一来源。
+> **编写:** 许清楚(产品经理)
+> **状态:** 📝 仅需求整理完成,**未实施任何代码**
+> **交付边界:** 本次**只新建并维护本 Markdown 文档**,未修改任何业务代码(前端 / 后端 / AI 服务 / 测试 / 迁移均零改动)
+> **基线:** 上一轮「家庭食材总览分段聚合口径」已完成并落库(迁移 465),本轮仅作口径确认与回归,不重复提出
+> **来源:** 用户 2026-11-10 原始诉求 5 句(逐字保留见 1.4)
+> **关联规范:** `docs/superpowers/api/API_REFERENCE.md`(新增接口前必须查重);命名风格参考 `docs/需求分析/2026-11-06-我的团队收益口径与邀请分享需求变更.md`
+> **配套技术方案:** `docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md`(架构师高见远;v1.1 的事实订正与裁定状态回写依据)
+
+---
+
+## 一、变更背景与范围
+
+### 1.1 变更性质与交付边界
+
+本次为**增量需求变更**,针对已上线的「食材查询 / 今日食谱」模块(`recommendation.vue` 4 Tab 页面)的 4 类问题:1 项口径确认(基线)、2 个 Bug、1 项规则新增、1 项交互优化。
+
+| 项 | 说明 |
+|----|------|
+| 本次范围 | **只整理需求、只产出本文档**;需求评审、技术方案、编码、测试、迁移、接口登记均**不在本次** |
+| 允许改动 | 仅新建 `docs/需求分析/2026-11-10-食材推荐食谱需求变更.md` 一个文件 |
+| 禁止改动 | 任何业务代码文件(`cfc-frontend/**`、`cfc-backend/**`、`cfc-langgraph/**`)、任何测试文件、任何数据库迁移 |
+| 后续交接 | 架构师基于本文档做变更影响面分析与增量任务分解(团队任务 #2) |
+
+### 1.2 涉及模块 / 页面 / 接口
+
+| 层级 | 位置 | 本次涉及点 |
+|------|------|-----------|
+| 小程序页面 | `cfc-frontend/pages/diet/recommendation.vue`(Vue2 Options API) | 成员切换条联动、菜品卡片操作区、删除后空/加载/失败态、生成与刷新载荷 |
+| 前端 API 层 | `cfc-frontend/utils/api.js`(L3128–3132) | 5 个 export:`getDietRecommendation` / `generateDietRecommendation` / `updateDietRecommendation` / `regenerateDietRecommendation` / `completeDietRecommendation`。**F-13:页面 L292 只 import 了其中 4 个,未引入 `updateDietRecommendation`** → 删除功能只需在 vue 补 1 行 import,`api.js` 零改动 |
+| 后端 Controller | `cfc-backend/.../controller/diet/DietRecommendationController.java` | `/today`、`/generate`、`/update`、`/regenerate`、`/complete`(统一 `@PostMapping` + `Result<T>`) |
+| 后端 Service | `cfc-backend/.../service/DietRecommendationService.java` | 今日查询、生成幂等键、共餐人来源、菜单归一化、降级菜单、重新生成 |
+| AI 网关 | `cfc-backend/.../service/AiGateway.java`(L585–614) | `generateMenu(selectedFoodsJson, participantsJson, date)` |
+| AI 服务 | `cfc-langgraph/app/api/meal.py`(`POST /api/v1/menu/generate`、`MENU_GENERATE_TEMPLATE`) | 菜数要求入口、prompt 模板(可由 Java 侧 `get_prompt("menu_generate")` 覆盖) |
+| 数据 | 表 `diet_recommendations`(`menu_json` / `participant_member_ids` / `version` / `status` / `meal_type` / `family_id` / `recommendation_date`)、表 `meal_config`、表 `family_food_overview`(基线) | 视角维度、菜数落库、共餐人口径 |
+| 已有测试 | `cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js`、`recommendation.participantSelect.test.js`、`cfc-backend/src/test/java/com/etotem/cfc/unit/ReportDietRecommendContractTest.java`、基线 `cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java` | 回归门禁(见第八节) |
+
+**接口查重结论(v1.1 订正,已查 `API_REFERENCE.md` + 实读实现):** 4.11 节已登记 `/api/diet/recommendation/today|generate|complete` 与 `/api/diet/meals/config|config/save`;4.63.3 节已登记 `/api/meal/replace`、`/api/diet/recommendation/update|regenerate|complete`。
+- **`/api/meal/replace` 语义不符,明确不复用**(F-16):实为 `MealRecommendController#replace`(L72–85),`@RequestAttribute("userId")` + `{foodId}` → `Result<Food>`「换一个食材」,**完全不操作 `diet_recommendations.menu_json`**;`API_REFERENCE.md` 4.59.6.3(L2905)写「替换后菜单 / 换一道菜」与实现有偏差,须在接口登记时如实标注。
+- **删除菜品复用既有 `POST /api/diet/recommendation/update`**(`api.js` L3131 已有 export,vue 补 1 行 import 即可,F-13),**不新增端点**;但必须规避 R-02 的 `status="adjusted"` 陷阱。
+- 查重结论:4.11 / 4.63.3 / 4.59.6.3 / 契约表(L3256–3258)**均无「删除菜品」端点**;若后续改选「新增专用删除端点」,须先查重并在 4.11 节登记,不得重复造接口(备选规范见架构师文档 §3.7)。
+
+### 1.3 基线:上一轮已完成变更(本轮不重复提出)
+
+| 项 | 已落地口径 |
+|----|-----------|
+| 表 | `family_food_overview`(`schema.sql` L4249 起,注释「迁移465」);`DatabaseInitializer.java` L14872 迁移 465 补 `overview_score` / `aggregate_rule` 列 |
+| 分段聚合 | 同一食材有任一成员推荐指数 `< 0` → 取**全员最低值**(`MIN_NEGATIVE`);全员 `>= 0` → 取**平均值**(`AVG_POSITIVE`) |
+| 取整 | 平均值**四舍五入取整**,**0 分计入平均**(分母含 0 分成员) |
+| 展示 | `overviewScore` 为展示值;`minIndexScore` 保留真实成员最小值做兼容(`recommendation.vue` L700–726 优先 `overviewScore`,缺失回退 `minIndexScore`) |
+| 前端降级 | `buildFamilyOverview()`(L753–778)同口径:`hasNegative ? minScore : Math.round(sum / memberScores.length)` |
+| 状态 | **迁移 465 已落库、已实现、已有单测覆盖**(`FamilyFoodOverviewServiceTest.java` L28–30 明确「四舍五入取整,0 分计入平均」) |
+
+> 用户本轮第 1 句「平均值四舍五入取整。」是对上述口径的**确认**,记为 **CR-01 口径确认 / 基线项**,**不是待开发需求**;本轮只要求回归验证,验收依据见 4.4。
+
+### 1.4 用户原始诉求(逐字保留)
+
+| # | 原话 | 归类 |
+|---|------|------|
+| 1 | 「平均值四舍五入取整。」 | 口径确认(基线 CR-01) |
+| 2 | 「食材查询页面,今日食谱并没有随上面选择成员切换,所有人看到的食谱是一样的。」 | Bug(CR-02 / CR-A) |
+| 3 | 「生成食谱后,点替换没触发重新生成。路过换成"删除"。」 | Bug + 交互优化(CR-03 / CR-B、CR-05);「路过」歧义见 D1 |
+| 4 | 「食谱午、晚餐有些少,三口以下按4菜一汤的标准,超过三口每超过一口加一道菜」 | 规则新增(CR-04 / CR-C) |
+| 5 | 「这些内容都整理成需求变更」 | 文档交付(即本文档,不构成产品需求) |
+
+### 1.5 现状核实:关键代码事实(主理人提供 + 本次复核)
+
+| 编号 | 事实 | 定位 |
+|------|------|------|
+| F-01 | `loadRecommendation()` 只传 `{date}`;`onLoad` / `onShow` 均调用它 | `recommendation.vue` L429 / L435 / L441–457 |
+| F-02 | `switchMember()` 只重载食材 `loadFoods()`,**不重载今日食谱**;`switchView('family')` 清空 `currentMemberId`;`onRefresh()` 只刷食材与总览 | L862–868 / L845–853 / L869–877 |
+| F-03 | 后端 `getTodayRecommendation(familyId, date)` 按 `family_id + recommendation_date + status IN (pending, accepted, completed)` 查询并取 `records.get(0)`,**完全不区分成员/共餐人**;返回体含 `participantMemberIds` 但前端未使用 | `DietRecommendationService.java` L37–65 |
+| F-04 | `generateRecommendation()` 幂等键 = `family_id + date + meal_type`(`all`),同日同家庭只保留一条,重复生成覆盖 | L100–112 |
+| F-05 | 前端个人/成员视角传 `participant_member_ids=[当前成员]`,家庭总览先弹共餐人多选再传所选;**后端 `generateRecommendation()` 未读取该字段**,共餐人来自 `queryParticipantIds()` 查 `meal_config`(无配置则全家庭成员) | 前端 L504–512 / L555–563;后端 L67–79 / L214–255 |
+| F-06 | `swapDish()` 仅一句 `uni.showToast({title:'已替换菜品'})`,无接口、无数据变更、无重新生成;`likeDish()` / `skipDish()` 同为纯提示空实现 | L581–589;模板 L206–210 |
+| F-07 | 页面「重新生成」`regenerate()` 调 `POST /api/diet/recommendation/regenerate` | L590–606 |
+| F-08 | `normalizeMenu(menuJson, participantCount)` 原样透传 `meals[].dishes`,**不校验菜数** | L270–316 |
+| F-09 | AI 失败降级 `buildFallbackMenu()` 每餐仅 1 道「营养餐」,**无汤、无按人数定菜数** | L343–374 |
+| F-10 | `aiGateway.generateMenu(selectedFoodsJson, participantsJson, date)` 仅传食材 / 共餐人 ID / 日期,**未传菜数要求**;LangGraph 侧模板仅要求「早/午/晚各至少 1-2 道菜」 | `AiGateway.java` L585–614;`cfc-langgraph/app/api/meal.py` L26–40 / L111 |
+| F-11 | `/api/diet/recommendation/update` 已存在;`updateRecommendation()` 将 `status` 置为 `"adjusted"` | Controller L41–50;Service L142–157;`api.js` L3131 |
+| F-12 | 家庭总览分段聚合口径已实现且前端降级路径同口径(`Math.round`) | `recommendation.vue` L700–726 / L753–778 |
+
+**复核补充事实(v1.1 新增,与架构师文档 §0.2 编号对齐,均已实读复核)**
+
+| 编号 | 事实 | 定位 | 对需求 / 验收的影响 |
+|------|------|------|--------------------|
+| F-13 | 页面 `recommendation.vue` L292 **只 import 4 个食谱 API**,未引入 `updateDietRecommendation`;但 `api.js` L3131 已定义该 export | `recommendation.vue` L292;`api.js` L3128–3132 | 删除功能复用 `/update` 时 **`api.js` 零改动**,仅 vue 补 1 行 import;3 个前端测试桩(F-21)需同步补桩,否则调用删除方法会 `undefined is not a function` |
+| F-16 | `/api/meal/replace` 实为 `MealRecommendController#replace`(L72–85),`@RequestAttribute("userId")` + `{foodId}` → `Result<Food>`「换一个食材」,**完全不触碰 `diet_recommendations.menu_json`**;`API_REFERENCE.md` 4.59.6.3(L2905)却写「替换后菜单」,文档与实现有偏差 | `MealRecommendController.java` L71–85;`API_REFERENCE.md` L2900–2908 | **无论按文档还是按实现都不是「删除菜品」语义 → 明确不复用**;1.2 / 4.2 查重结论据此订正 |
+| F-17 | `get_prompt("menu_generate")` 走 Java `/api/system-prompts/get` 读 `system_prompts` 表,**300s 进程内缓存**;`seed_system_prompts.sql` L379–409 **已存在 `status=1` 启用的 `menu_generate` 记录**,内容仍要求「早/午/晚各至少 1-2 道菜」,`ON DUPLICATE KEY UPDATE` 只更新 `default_content` **不覆盖运营改过的 `content`**;`meal.py` 的 `template.format(...)` **只传 4 个占位符** | `cfc-langgraph/app/prompt_service.py` L12–14 / L100–120 / L123–130;`seed_system_prompts.sql` L379–409;`cfc-langgraph/app/api/meal.py` L127–141 | **只改 Python 本地模板一定不生效**(R-05 实锤);菜数要求不得新增占位符,应注入既有 `{constraints_text}`。落地清单见架构师文档 §4.4;正确性由 Java 侧归一化兜底保证,不依赖 prompt |
+| F-20 | `ReportDietRecommendContractTest.java` 实为 **`FoodRecommendService.recalculateForUser`** 的契约测试,**与 `DietRecommendationService` / `/today` / `/generate` 无直接耦合** | `ReportDietRecommendContractTest.java` L38–49、L50–257 | 它只作「不破坏」门禁,**不是** `/today` / `/generate` 载荷变更的回归基线;本次须**新增** `DietRecommendationService` 单测(8.3 已订正) |
+
+### 1.6 关联问题与实现约束(供架构师核实,**本 PRD 不拍板方案**)
+
+| 编号 | 问题 / 约束 | 影响 | 处理建议 |
+|------|------------|------|---------|
+| R-01 | **前后端共餐人口径不一致**:前端传 `participant_member_ids`,后端只读 `date` / `meal_type` / `selected_foods`,实际共餐人来自 `meal_config`(F-05) | CR-A「按当前视角共餐人生成」、CR-C「N = 所选共餐人数」均无法落地;现有 `recommendation.participantSelect.test.js` 只断言前端载荷,未覆盖后端 | 作为 CR-A/CR-C 的前置依赖,纳入 D6 确认;**架构师已裁定采用 D6①**(后端优先读前端载荷、`meal_config` 仅兜底),技术方案见其文档 §1.1-③ |
+| R-02 | **`status = "adjusted"` 不在今日查询白名单内**:`updateRecommendation()` 置 `status="adjusted"`,而 `getTodayRecommendation()` 只查 `pending/accepted/completed`(F-03 + F-11) | 若「删除菜品」直接复用 `/update` 落库,删除后今日食谱会查不到 → 页面回到空态,属回归缺陷 | **架构侧处理建议(技术方案,见其文档 §1.1-② / §3.3)**:复用 `/update`,去掉 `setStatus("adjusted")` 赋值、保持原 `status` 不变、不扩大 `/today` 白名单、不新增删除端点;`/api/meal/replace` 语义不符不复用(F-16)。**验收 AC-18 必须覆盖** |
+| R-03 | **`regenerate` 不真正生成菜单**:`regenerateRecommendation()` 仅复制记录、`menu_json="{}"`、`version+1`,不调用 AI | 用户原话「点替换没触发重新生成」;PRD 4.3 要求 AI / 降级 / 重新生成三条路径均满足菜数、AC-27 与 G-3 门禁;页面「重新生成」当前返回空菜单 | **✅ 用户 2026-11-10 确认纳入本轮**(按推荐方案;依据诉求 3 / AC-27 / G-3)。产品规则要求重新生成路径真正走 AI / 降级 / 归一化、不得返回空菜单,实现由架构师 §1.1-⑤ 裁定。**副作用(须写入产品说明)**:① 重新生成把 `completed` 重置为 `pending`(「标记完成」按钮显隐恢复);② 不再保留历史版本行(覆盖同视角记录)。见第七节第 7 条 |
+| R-04 | **幂等键过粗**:`family_id + date + meal_type` 导致同日同家庭只有一条记录(F-04) | 是「所有人看到同一份食谱」的直接根因之一 | 数据隔离 / 幂等键调整方案**由架构师裁定**;PRD 只约束可观察行为(AC-02 / AC-08),不把 `view_key` 等技术方案写入产品规则 |
+| R-05 | **【实锤】LangGraph prompt 双来源 + 300s 缓存**:`get_prompt("menu_generate")` 读 Java `system_prompts` 表(`seed_system_prompts.sql` L379–409 已有 `status=1` 启用记录)优先于本地 `MENU_GENERATE_TEMPLATE`;`ON DUPLICATE` 只更新 `default_content` 不覆盖 `content`、`prompt_service.py` 有 300s 缓存、`template.format` 只传 4 占位符(F-17 / F-18) | **只改 Python `MENU_GENERATE_TEMPLATE` 一定不生效**;菜数要求若只落 Python,AI 仍按 1-2 道菜生成 | 落地清单见架构师文档 §4.4:注入既有 `{constraints_text}`(不新增占位符)+ 同步 `seed_system_prompts.sql` 文案 + 幂等更新线上 `content` + `clear_cache('menu_generate')`。**菜数硬保证已裁定落在 Java `normalizeMenu()`**(拆分 `dish.type`、不足补齐、超出截断、不重试 AI);**prompt 仅软引导**,正确性不依赖其生效 |
+| R-06 | 新增接口前必须查 `docs/superpowers/api/API_REFERENCE.md` 4.11 / 4.63.3 / 4.59.6.3 节与契约表(L3256–3258) | 避免重复端点;**`/api/meal/replace` 语义不符,明确不复用**(F-16) | 硬约束,写进任务分解;4.59.6.3 的「替换后菜单」文档偏差须如实标注 |
+
+---
+
+## 二、需求变更清单(CR-01 … CR-05)
+
+| 编号 | 类型 | 优先级 | 关联原始诉求 | 一句话描述 | 主要影响面 |
+|------|------|--------|-------------|-----------|-----------|
+| **CR-01** | **口径确认 / 基线项** | 基线(已实现,仅回归验证) | 诉求 1 | 家庭总览平均值「四舍五入取整、0 分计入平均」为上一轮已落地口径,本轮确认并纳入回归,**不作为待开发需求** | `family_food_overview`、迁移 465、`FamilyFoodOverviewServiceTest`、前端 `overviewScore` 展示 |
+| **CR-02** | **Bug** | **P0** | 诉求 2 | 今日食谱不随顶部成员 / 家庭总览切换,所有人看到同一份;须做到「切换视角 → 食谱随视角刷新,不同视角可有不同食谱」 | 前端 `loadRecommendation` / `switchMember` / `switchView` / `onRefresh`;后端 `/today`、`/generate`、幂等键、数据隔离 |
+| **CR-03** | **Bug** | **P0** | 诉求 3 | 「🔄 替换」点击无任何效果(纯 toast 空实现)是 Bug 事实;期望改为「🗑 删除」,点击后该菜从当前食谱移除并有明确反馈 | 前端菜品卡片模板与 `swapDish`;**复用 `/update`(`/api/meal/replace` 语义不符不复用,F-16)**;R-02 `status` 处理、状态与空态 |
+| **CR-04** | **规则新增** | **P1** | 诉求 4 | 午餐、晚餐按共餐人数 N 定菜数:N ≤ 3 → 4 菜 1 汤;N > 3 → 菜数 = 4 + (N − 3),汤恒 1 | `AiGateway.generateMenu`、LangGraph prompt、`normalizeMenu`、`buildFallbackMenu`、`menu_json` 结构、`participant_member_ids` |
+| **CR-05** | **交互优化** | **P1** | 诉求 3(衍生)+ 诉求 5(文档化收口) | 收敛菜品卡片空实现按钮组合,补齐删除后的空态 / 加载态 / 失败态与防重复点击 | `recommendation.vue` 模板与样式、交互状态、`recommendation.uiRework.test.js` |
+
+> 优先级口径:**P0 = Must have**(当前功能不可用 / 用户可直接感知的错误,须优先修复);**P1 = Should have**(本轮用户明确提出的规则与体验);**P2 = Nice to have**(本轮无)。CR-01 为已完成基线,不参与排期,仅作为回归门禁。
+
+---
+
+## 三、用户故事
+
+| # | 用户故事 |
+|---|---------|
+| US-1 | 作为**家长**,我在食材查询页顶部从「成员 A」切到「成员 B」时,希望「今日食谱」跟着切换到该成员对应的食谱,以便我确认每个人今天吃什么,而不是所有人看到同一份。 |
+| US-2 | 作为**家长**,我切到「家庭总览」时,希望看到的是按本次所选共餐人生成的家庭食谱;点「生成食谱」时仍先弹共餐人多选,确认后按所选人数生成,避免误用某个成员的食谱。 |
+| US-3 | 作为**查看食谱的家庭成员**,当某道菜不合口味时,我希望点「🗑 删除」就能把它从当前食谱中移除并看到明确反馈,而不是点「替换」后只弹一句「已替换菜品」却什么都没变。 |
+| US-4 | 作为**为全家备餐的家长**,我希望午餐和晚餐按共餐人数给足菜品:三口及以下至少 4 菜 1 汤,超过三口每多 1 人再加 1 道菜,这样不用自己再补菜。 |
+| US-5 | 作为**使用者**,我希望删完某餐的菜、切换成员刷新、或操作失败时,页面都有清晰的空态 / 加载态 / 失败提示,不会白屏、不会串到其他人的食谱、也不会丢数据。 |
+
+---
+
+## 四、逐条需求详述
+
+> **编号映射:** 第四节用 CR-A / CR-B / CR-C 组织详述(与主理人任务口径一致),对应第二节清单编号如下:`CR-A = CR-02`、`CR-B = CR-03 (+ CR-05)`、`CR-C = CR-04`;`CR-01` 单列于 4.4(基线,非待开发)。
+
+### 4.1 CR-A(清单 CR-02)· 今日食谱随成员 / 视角切换
+
+**现状**
+- `loadRecommendation()` 只传 `{date}`(F-01);`switchMember()` 只重载食材、`switchView('family')` 清 `currentMemberId`、`onRefresh()` 不刷食谱(F-02)。
+- 后端按 `family_id + date` 取 `records.get(0)`,不区分成员 / 共餐人(F-03);`generateRecommendation()` 幂等键 `family_id + date + meal_type` 使同日同家庭只保留一条,重复生成覆盖(F-04)。
+- 前端传 `participant_member_ids`,后端未读取、改从 `meal_config` 取(F-05 / R-01)。
+
+**期望行为**
+- 在「今日食谱」Tab 下,**切换成员或切到家庭总览后,今日食谱内容应与该视角一致,不能所有人看到同一份**。
+- 视角定义:① 「个人 / 某成员」= 该成员视角;② 「家庭总览」= 本次所选共餐人组合视角(生成前先弹共餐人多选,默认全选,确认后生成)。
+- 生成食谱时**按当前视角的共餐人**:个人/成员视角 = 当前选中成员 1 人;家庭总览视角 = 共餐人多选所选成员集合。
+- 无食谱时显示空态,不回退展示其他视角的食谱。
+- **本 PRD 不指定数据隔离 / 存储方案**(是否按成员或共餐人组合拆分记录、是否新增维度字段、幂等键如何调整、旧数据如何兼容),**由架构师在技术方案中裁定**;PRD 只约束以下可观察行为与验收结果(AC-02 / AC-08 / AC-10)。
+
+**验收标准(AC)**
+
+- [ ] **AC-01** 在「今日食谱」Tab 下点击成员 A,触发今日食谱重新加载(请求携带当前视角 / 成员标识),显示局部加载态,不残留上一成员食谱。
+- [ ] **AC-02** 点击成员 B 后显示 B 视角食谱;**不同成员可看到不同食谱**,各自生成的记录互不被覆盖。
+- [ ] **AC-03** 从成员视角切到「家庭总览」后,今日食谱重新加载为家庭总览视角食谱,不沿用成员视角食谱。
+- [ ] **AC-04** 任一视角当日无食谱时,显示空态「今日菜单空着 / 点击生成今日三餐食谱」+「生成食谱」按钮;不显示他人食谱、不报错、不出现残留数据。
+- [ ] **AC-05** 个人 / 成员视角点「生成食谱」→ 以当前选中成员作为共餐人生成,成功后刷新出该视角食谱。
+- [ ] **AC-06** 家庭总览视角点「生成食谱」→ **仍先弹共餐人多选**(默认全选家庭成员),确认后按所选共餐人生成,成功后刷新出家庭总览视角食谱;共餐人多选交互不得被本次改动破坏。
+- [ ] **AC-07** 生成结果中「N 人份」与所选共餐人数一致(与 CR-C 的 N 口径联动,见 AC-20)。
+- [ ] **AC-08** 为某视角生成 / 重新生成食谱,**不覆盖其他成员 / 其他视角当日已有食谱**。
+- [ ] **AC-09** `onShow` 与下拉刷新 `onRefresh` 同样按**当前视角**重载今日食谱,不串视角、不重置为用户默认视角。
+- [ ] **AC-10** 数据隔离与幂等键方案由架构师裁定并在技术方案中说明;实现后须通过 AC-02 / AC-08,且不得引入新端点前未查 `API_REFERENCE.md` 的重复接口。
+
+**影响范围**
+- 前端:`recommendation.vue`(`loadRecommendation` 载荷、`switchMember` / `switchView` / `onRefresh` / `onShow` 联动、`generateRecipe` / `confirmParticipants` 载荷、加载态)。
+- 后端:`DietRecommendationController#/today`、`#/generate`;`DietRecommendationService#getTodayRecommendation`、`#generateRecommendation`(幂等键、共餐人来源 R-01)。
+- 数据:`diet_recommendations`(视角维度 / 幂等键,方案待裁定)、`meal_config`。
+- 测试:`recommendation.uiRework.test.js`(switchView / switchMember 契约)、`recommendation.participantSelect.test.js`(共餐人多选契约)、`ReportDietRecommendContractTest.java`(**F-20:仅「不破坏」门禁,不覆盖本次接口**);**需新增 `DietRecommendationService` 单测**覆盖视角查询与生成契约。
+
+---
+
+### 4.2 CR-B(清单 CR-03)· 「替换」无反应 Bug 与改为「删除」
+
+**现状(Bug 事实,须在需求中如实记录)**
+- `swapDish(mealName, dishIndex)`(L584–586)**只有一句** `uni.showToast({title:'已替换菜品'})`:**没有调用任何接口、没有修改菜单数据、没有触发重新生成**。用户「点替换没触发重新生成」属实,是**功能空实现 Bug**,不是「替换算法不准」。
+- 同页 `likeDish()`「👍 好吃」、`skipDish()`「✕ 跳过」同样是纯提示空实现(F-06)。
+- 页面另有「重新生成」按钮 `regenerate()` 调 `/api/diet/recommendation/regenerate`,但该后端方法不真正生成菜单(R-03,属关联风险,另议)。
+
+**期望行为**
+- 菜品卡片上的「🔄 替换」**改为「🗑 删除」**(图标 + 文案)。
+- 点击「🗑 删除」后,**该菜从当前食谱中移除**,并给出明确反馈(toast「已删除」+ 列表即时更新)。
+- 删除只作用于**当前视角、当前餐次、当前这一道菜**,不影响其他餐次 / 其他菜品 / 其他成员视角食谱。
+- 删除结果需可持久(切走再回来不再出现),若采用不落库方案则须在交互上明确说明其作用范围。
+- **D2 已按推荐①确认**:删除后**不静默补菜**;页面显示「当前 N 菜 1 汤 / 未达标准」只读状态提示,删除与补菜解耦。
+
+**验收标准(AC)**
+
+- [ ] **AC-11** Bug 事实在回归用例中可被断言:现有「🔄 替换」点击仅弹 toast、不请求接口、不改 `meals` 数据、不重新生成(修复前保留该事实基线)。
+- [ ] **AC-12** 菜品卡片「🔄 替换」已替换为「🗑 删除」(文案与图标一致),页面不再出现「替换」按钮(除非 D1 最终选择「保留替换并修复」方案)。
+- [ ] **AC-13** 点击「🗑 删除」后该菜从当前食谱移除,且出现明确反馈(toast + 列表刷新),不是仅弹提示。
+- [ ] **AC-14** 删除结果在当前视角内持久:切走再切回 / 重新进入页面,该菜不再出现(若采用落库方案);不落库方案须明示作用范围并经确认。
+- [ ] **AC-15** 删除不影响其他餐次、其他菜品、其他成员 / 视角的食谱。
+- [ ] **AC-16** 删除失败(网络 / 接口异常)→ toast 失败提示,**原菜品保留**,可重试;不出现「界面删了但服务端没删」的静默不一致。
+- [ ] **AC-17** 「删除后是否自动补菜以满足菜数标准」按 **D2** 裁定执行;未确认前不静默补菜,且页面能表达「当前 N 菜 1 汤 / 未达标准」或裁定后的展示口径。
+- [ ] **AC-18** 若复用 `POST /api/diet/recommendation/update` 保存删除结果,**不得导致今日食谱查询为空**(R-02:`status="adjusted"` 不在今日查询白名单内);需通过「删除 → 刷新页面 → 食谱仍可见且不含该菜」的回归用例。
+- [ ] **AC-19** 菜品卡片其余按钮组合按第六节推荐方案呈现(D1 已按推荐①确认)。
+
+**影响范围**
+- 前端:`recommendation.vue` 模板 L206–210、`swapDish` / `likeDish` / `skipDish`、删除态与防重复点击。
+- 后端 / 接口:**复用 `POST /api/diet/recommendation/update`**(`api.js` L3131 已有 export,vue 补 1 行 import 即可,F-13);**`/api/meal/replace` 语义不符,明确不复用**(F-16);**不新增端点**(技术方案见架构师文档 §1.1-② / §3.3)。须处理 R-02 的 `status="adjusted"` 陷阱;若改选「新增专用删除端点」,须先查重并登记 4.11 节(备选规范见其 §3.7)。
+- 数据:`diet_recommendations.menu_json`、`status`。
+- 测试:`recommendation.uiRework.test.js`(模板契约 / 回归防护段)、`ReportDietRecommendContractTest.java`(**仅「不破坏」门禁**,F-20);3 个前端测试桩需同步补 `updateDietRecommendation` 桩(F-21),另需新增 `DietRecommendationService` 单测。
+
+---
+
+### 4.3 CR-C(清单 CR-04)· 午 / 晚餐按共餐人数定菜数
+
+**现状**
+- `normalizeMenu(menuJson, participantCount)` 原样透传 LangGraph 返回的 `meals[].dishes`,**不校验数量**(F-08)。
+- AI 失败降级 `buildFallbackMenu()` 每餐只生成 1 道「营养餐」,**无汤、无按人数定菜数**(F-09)。
+- `aiGateway.generateMenu(selectedFoodsJson, participantsJson, date)` 未传菜数要求;LangGraph 模板仅要求「早/午/晚各至少 1-2 道菜」(F-10),且模板可被 Java 侧 prompt 覆盖(R-05)。
+
+**期望行为:规则表**
+
+| 项 | 规则 |
+|----|------|
+| 适用餐次 | **仅午餐 `lunch`、晚餐 `dinner`**;早餐维持现状(是否纳入见 **D3**) |
+| 人员口径 | `N` = **本次生成所选共餐人数**(即 `participant_member_ids` 数组长度),**不是家庭总人数**;`N >= 1`,`N = 0` / 空按 `N = 1` 兜底(与现有 `normalizeMenu` / LangGraph `participant_count` 兜底一致) |
+| 汤数 | `S(N) = 1`(恒定,**只加菜不加汤**) |
+| 菜数 | `D(N) = 4 + max(0, N − 3)`;即 `N ≤ 3 → D = 4`,`N > 3 → D = 4 + (N − 3) = N + 1` |
+| 每餐合计 | `T(N) = D(N) + S(N) = 5 + max(0, N − 3)` 道(汤是否计入「加一道菜」见 **D4**;默认口径:**汤不计入菜数**) |
+| 生成路径 | AI 正常路径、AI 失败降级路径(`buildFallbackMenu`)、重新生成路径,**三条路径均须满足上述规则**;**产品规则要求重新生成路径同样满足本规则**(须真正走 AI / 降级 / 归一化,不得返回空菜单;AC-27 / G-3);**R-03 已确认纳入本轮**(见 1.6 R-03、6.1、第七节第 7 条),三条路径均须实现 |
+| 可识别性 | 返回结果中须能区分「哪 1 道是汤」与 `D(N)` 道菜(具体字段 / 结构由架构师裁定,PRD 不指定) |
+
+**计算公式**
+
+```
+N = |participant_member_ids|                     (N = 0 时取 1)
+D(N) = 4 + max(0, N - 3)                        菜数
+S(N) = 1                                         汤数(恒 1)
+T(N) = D(N) + S(N) = 5 + max(0, N - 3)          午餐 / 晚餐每餐合计
+仅 lunch / dinner 适用;breakfast 维持现状(待 D3 确认)
+```
+
+**示例表(N = 1 … 6)**
+
+| 共餐人数 N | 菜数 D(N) | 汤数 S(N) | 每餐合计 T(N) | 说明 |
+|:---:|:---:|:---:|:---:|------|
+| 1 | 4 | 1 | 5 | 三口以下:4 菜 1 汤 |
+| 2 | 4 | 1 | 5 | 三口以下:4 菜 1 汤 |
+| 3 | 4 | 1 | 5 | 三口以下(基准):4 菜 1 汤 |
+| 4 | 5 | 1 | 6 | 超过三口,每多 1 人加 1 道菜 |
+| 5 | 6 | 1 | 7 | 同上 |
+| 6 | 7 | 1 | 8 | 同上 |
+
+**验收标准(AC)**
+
+- [ ] **AC-20** 规则仅对 `lunch` / `dinner` 生效;`N` 取自本次生成所选共餐人数(非家庭总人数),`N = 0` / 空按 1 处理(与 CR-A AC-07 联动)。
+- [ ] **AC-21** 午餐 / 晚餐各返回 `D(N)` 道菜 + 1 道汤,`D(N) = 4 + max(0, N − 3)`,汤恒为 1,只加菜不加汤。
+- [ ] **AC-22** 示例表逐项可验:N=1/2/3 → 4 菜 1 汤;N=4 → 5 菜 1 汤;N=5 → 6 菜 1 汤;N=6 → 7 菜 1 汤。
+- [ ] **AC-23** AI 生成路径(`AiGateway.generateMenu` → `/api/v1/menu/generate`)**携带菜数要求**;返回结果经 `normalizeMenu` 后仍满足 AC-21(现状原样透传,需增加校验 / 补齐能力,方案由架构师裁定)。
+- [ ] **AC-24** 降级路径 `buildFallbackMenu()` 同样满足 AC-21,不再出现「每餐 1 道营养餐、无汤」。
+- [ ] **AC-25** 返回结果可区分 1 道汤与 `D(N)` 道菜;前端「N 人份」及菜数展示与之相符。
+- [ ] **AC-26** AI 返回菜数不足时的处理按 **D5** 裁定执行;用户可见结果不得出现「午 / 晚餐少于 4 菜 1 汤」(除非 D5 明确选择「提示重试」口径)。
+- [ ] **AC-27** 「重新生成」或同一视角再次生成时,菜数规则仍生效。
+- [ ] **AC-28** 若菜数要求通过 LangGraph prompt 下发,须同时核对 Java 侧 `get_prompt("menu_generate")` 覆盖路径(R-05),确保规则实际生效。
+
+**影响范围**
+- 后端:`DietRecommendationService#generateRecommendation` / `#normalizeMenu` / `#buildFallbackMenu` / `#queryParticipantIds`(N 来源,关联 R-01)、`AiGateway#generateMenu` 入参。
+- AI 服务:`cfc-langgraph/app/api/meal.py`(`MENU_GENERATE_TEMPLATE` / `MenuGenerateRequest` / prompt 配置)。
+- 数据:`diet_recommendations.menu_json` 结构(汤 / 菜可区分)、`participant_member_ids`。
+- 前端:`parseMenu()`、餐次头部「N 人份 / 菜数」展示(可选)。
+- 测试:`ReportDietRecommendContractTest.java`、新增菜数规则单测(由架构师分解)。
+
+---
+
+### 4.4 CR-01(清单 CR-01)· 口径确认 / 基线项(**非待开发需求**)
+
+**性质**:用户本轮第 1 句「平均值四舍五入取整。」是对上一轮已完成变更的**确认**。本节只记录**验收依据与回归核验项**,不产生开发任务。
+
+| 验收依据 | 位置 |
+|---------|------|
+| 表结构 + 注释「迁移465」 | `cfc-backend/src/main/resources/schema.sql` L4249–4266(`aggregate_rule VARCHAR(24) MIN_NEGATIVE / AVG_POSITIVE`) |
+| 迁移 465 补列 | `cfc-backend/.../config/DatabaseInitializer.java` L14872–14885(`overview_score`、`aggregate_rule`) |
+| 口径单测 | `cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java` L28–30 明确「四舍五入取整,0 分计入平均」「minIndexScore 始终是真实成员最小值」;L116–137 / L431–446 / L459–514 覆盖 `overviewScore` / `aggregateRule` / 历史行自愈 |
+| 前端展示与兼容 | `recommendation.vue` L700–726 优先 `overviewScore`、缺失回退 `minIndexScore` |
+| 前端降级同口径 | `recommendation.vue` L753–778:`hasNegative ? minScore : Math.round(sum / memberScores.length)` |
+
+**回归核验项(基线,不参与本轮排期)**
+
+- [ ] **AC-29** 家庭总览:同一食材有任一成员指数 `< 0` → `aggregateRule = MIN_NEGATIVE`,展示值取全员最低值;全员 `>= 0` → `AVG_POSITIVE`,展示值取平均值。
+- [ ] **AC-30** 平均值**四舍五入取整**,且 **0 分计入平均**(分母含 0 分成员)。
+- [ ] **AC-31** `minIndexScore` 仍为真实成员最小值;`overviewScore` 缺失时前端回退 `minIndexScore` 不报错。
+- [ ] **AC-32** 迁移 465 已落库,历史行(`overview_score IS NULL`)读取侧自愈按新口径重算。
+
+---
+
+### 4.5 CR-05(清单 CR-05)· 交互状态补充(与 CR-B 合并实施)
+
+**验收标准(AC)**
+
+- [ ] **AC-33** 菜品卡片操作区按第六节推荐组合呈现(组合本身待 **D1** 确认)。
+- [ ] **AC-34** 某餐菜数删至 0 时,该餐显示「本餐暂无菜品」空态,并提供「重新生成」入口;不显示空白卡片、不报错。
+- [ ] **AC-35** 全部菜品删完 → 整份食谱显示整体空态,可重新生成。
+- [ ] **AC-36** 切换成员 / 视角刷新食谱期间显示**局部加载态**(不整页遮罩),加载中不残留上一视角食谱造成串数据。
+- [ ] **AC-37** 删除操作期间按钮防重复点击 / 禁用;失败态按 AC-16 保留原数据并提示重试。
+- [ ] **AC-38** 加载 / 空 / 失败三态文案统一,不与「今日菜单空着」既有空态冲突。
+
+**影响范围**:`recommendation.vue` 模板(L156–255)、`data` 状态位、`methods`;`recommendation.uiRework.test.js` 模板契约与回归防护段。
+
+---
+
+## 五、交互与 UI 变更说明
+
+### 5.1 成员切换条 → 今日食谱联动
+
+| 触发 | 现状 | 期望 |
+|------|------|------|
+| 点击某成员 chip | 只 `loadFoods()` 重载食材,今日食谱不变 | 同时重载「今日食谱」(按该成员视角),局部加载态,见 AC-01 / AC-02 |
+| 点击「家庭总览」 | 清 `currentMemberId`,只影响食材区 | 同时重载「今日食谱」为家庭总览视角,见 AC-03 |
+| 下拉刷新 `onRefresh` | 只刷食材 + 家庭总览 | 增加按当前视角重载今日食谱,见 AC-09 |
+| `onShow` 回页面 | 只按 `{date}` 重载,可能串视角 | 按当前视角重载,见 AC-09 |
+| 切 Tab 回「今日食谱」 | 不重载 | 建议按当前视角重载(避免展示切换前残留),标注为建议项 |
+
+### 5.2 菜品卡片操作按钮组合
+
+| 项 | 现状 | **推荐方案(待 D1 确认)** |
+|----|------|--------------------------|
+| 按钮组 | `👍 好吃` / `🔄 替换` / `✕ 跳过`,**三者均为纯 toast 空实现** | **收敛为单一主操作「🗑 删除」**;「好吃」「跳过」本轮一并下线,不再保留无反馈的占位按钮 |
+| 主操作 | `🔄 替换`(无效果) | `🗑 删除`:图标 + 文案,点击移除该菜 + toast「已删除」+ 列表更新 |
+| 备选(D1 解释②) | — | 保留并**修复**「🔄 替换」(真正触发替换 / 重新生成),**另新增**「🗑 删除」,形成双按钮 |
+| 标注 | — | 按钮组合**按 D1 推荐①确认**:单一「🗑 删除」;「好吃 / 跳过」本轮下线,不实现其业务逻辑 |
+
+> 推荐理由:用户原话「路过换成"删除"」中「换成」表示**替换关系**(用删除替换掉替换),且「替换」本就是空实现,最小改动即可消除误导;保留空实现按钮会继续产生「点了没反应」的投诉。
+
+### 5.3 状态说明
+
+| 状态 | 表现 |
+|------|------|
+| **加载态** | 切换成员 / 视角、生成、删除、重新生成时显示局部 loading 或按钮禁用;删除沿用现有 `uni.showLoading` 风格;不整页遮罩、不清空后闪烁 |
+| **空态(视角级)** | 当日该视角无食谱 → 沿用现有「🍽️ 今日菜单空着 / 点击生成今日三餐食谱」+「生成食谱」按钮(AC-04) |
+| **空态(餐次级)** | 某餐 `dishes.length === 0` → 「本餐暂无菜品」+「重新生成」入口(AC-34) |
+| **空态(整份)** | 所有餐次菜数为 0 → 整份空态,可重新生成(AC-35) |
+| **成功态** | 删除 → toast「已删除」+ 列表即时更新;生成 → 沿用「食谱生成成功」 |
+| **失败态** | 网络 / 接口异常 → toast 明确失败原因(沿用现有 `(res && res.message) || '生成失败'` 风格),**原数据保留**,可重试(AC-16) |
+| **菜数展示(建议项)** | 午餐 / 晚餐头部在「N 人份」旁展示「D 菜 1 汤」标准提示,便于用户核对 AC-21;是否展示由 D1 / 架构师确认 |
+
+---
+
+## 六、问题裁定表(D1 … D6,原待确认项)
+
+| 编号 | 问题 | 候选解释 / 选项 | **推荐方案** | 不确认的后果 |
+|------|------|----------------|-------------|-------------|
+| **D1** | 原话「生成食谱后,点替换没触发重新生成。**路过**换成"删除"。」中「**路过**」疑为**输入法 / 语音误写**,语义不明;同时决定菜品卡片按钮组合 | ① **「路过」≈「干脆 / 直接」**:直接把「🔄 替换」按钮**换成**「🗑 删除」,不再修复替换逻辑(删除是唯一主操作);<br>② **保留替换并修复 + 新增删除**:先修好「替换」(真正触发重新生成 / 换菜),再额外加「🗑 删除」,形成双按钮;<br>③「路过」≈「掠过 / 跳过」:只把「✕ 跳过」改成「删除」,保留「替换」 | **推荐 ①**:把「🔄 替换」改为「🗑 删除」,不修替换;「好吃」「跳过」本轮一并下线。理由:原话「换成」是替换关系、替换本为空实现、改动最小、避免继续维护无反馈按钮。按钮组合随 ① 定为「单一删除」 | 按钮组合无法确定 → CR-03 / CR-05 无法进入开发;若误选 ② 会额外引入「替换是否真重生成」的实现范围(关联 R-03 `regenerate` 空菜单问题),工作量与验收口径都失控 |
+| **D2** | **删除某道菜后,是否自动补一道菜以满足 CR-C 的菜数标准?** | ① 不补菜,删除后菜数可低于标准,页面标注「当前 N 菜 1 汤 / 未达标准」;<br>② 自动补 1 道菜(同步或异步,保持 `D(N) 菜 1 汤`);<br>③ 弹出「是否补菜 / 重新生成」由用户选择 | **推荐 ①**(本轮先做「不静默补菜 + 明确状态提示」),补菜留待后续迭代;若产品要求即时足量,则选 ③ 由用户显式触发 | 与 CR-C「午晚餐足量」直接冲突:不确认会出现「删完剩 2 菜却声称符合 4 菜 1 汤」;若实现方擅自静默补菜,用户「删除」体感失效(删了又冒出来),且 AC-17 / AC-26 无法判定 |
+| **D3** | 早餐是否维持现状、不纳入菜数规则? | ① 早餐维持现状(现有 1-2 道 / 降级 1 道营养餐),规则只管午晚餐;<br>② 早餐同样适用 `4 菜 1 汤`;<br>③ 早餐另定标准(如 2 菜 1 主食) | **推荐 ①**:用户原话明确「食谱**午、晚餐**有些少」,早餐维持现状,最小改动 | 规则适用餐次不确定 → `normalizeMenu` / 降级菜单 / prompt 无法写死;若误把早餐也按 4 菜 1 汤,与用户表述相悖且改动面扩大 |
+| **D4** | 「每超过一口加一道菜」中的「一道菜」是否包含汤?汤是否计入 `D(N)`? | ① **汤恒 1 且不计入菜数**,只加菜不加汤(默认口径,`D(N)=4+max(0,N−3)`);<br>② 汤也算一道菜,N>3 时「+1」可落在汤或菜上不区分;<br>③ N>3 时汤也随人数增加 | **推荐 ①**:汤恒 1、只加菜,与「超过三口每超过一口加一道菜」+「4 菜 1 汤」字面一致,公式最简 | 菜数验收(AC-21 / AC-22)无判定基准;AI prompt 与降级菜单无法给出确定的 `dishes` 数量,示例表失效 |
+| **D5** | AI 返回的菜数不足 `D(N) 菜 1 汤` 时,是**补菜**还是**整份重新生成**? | ① 后端按规则**补齐**到 `D(N)+1`(复用可用食材 / 降级菜,不整份重生成);<br>② 整份重新调用 AI 生成(可重试 1 次,仍不足则降级);<br>③ 不处理,前端提示「菜数不足,点击重新生成」 | **推荐 ①**(不整份重生成,耗时与成本可控),并保留「仍不足则按 ③ 提示」的兜底;**最终由架构师裁定**(涉及 AI 调用链,PRD 不拍板实现) | `normalizeMenu` 现状原样透传,不确认则 AI 少给菜时用户仍看到「午晚餐有些少」,诉求 4 无法闭环;若选 ② 会引入重试 / 幂等 / 耗时的额外设计 |
+| **D6** | 共餐人数 `N` 的取值口径,以及**前端 `participant_member_ids` 未被后端读取**(R-01):`N` 到底以谁为准?数据隔离与幂等键归属? | ① 后端改为**优先读前端传入的 `participant_member_ids`**,`meal_config` 仅作兜底(与现有前端契约及 `participantSelect.test.js` 一致);<br>② 保持后端从 `meal_config` 取,前端生成前先把所选共餐人写入配置;<br>③ 前端不传,页面统一以 `meal_config` 为准;<br>④ 数据隔离方案(按成员 / 共餐人组合拆记录、加维度字段、幂等键调整)**由架构师裁定** | **推荐 ① + ④**:N = 本次生成所选共餐人数(前端载荷),保持「所见即所选」;隔离方案交由架构师在技术方案中定,PRD 不指定 | N 取值不确定 → CR-C 公式无法落地、AC-07 / AC-20 无法验收;CR-A「按当前视角共餐人生成」「不同成员食谱可不同」无法通过;现有 `participantSelect.test.js` 只覆盖前端载荷,后端口径不一致会长期潜伏 |
+
+> **D1 特别说明**:「路过」不是产品术语,在需求原文中无对应功能。本文档不臆造其含义,按「输入 / 语音误写」处理并给出上述合理解释;**若用户另有本意,以用户澄清为准,D1 结论可覆写**。
+
+### 6.1 裁定状态回写(v1.1 新增 · G-6 门禁追踪)
+
+> 依据架构师高见远《2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md》§1.2 / §9.1。**本节只记录「裁定状态」,不改变上表 D1~D6 的候选解释与推荐方案,也不把 `view_key` / 迁移 466 等技术方案写成产品规则**——技术方案以其文档为准,产品规则以本 PRD 的 38 条 AC 为准。
+
+| 编号 | 状态 | 结论 / 依据 | 裁定归属 | 架构文档落点 |
+|------|------|------------|-----------|-------------|
+| **D1** | ✅ **已确认(按推荐①)** | **替换→删除、好吃 / 跳过下线**;实现保留可回退设计(`dishActions` 数据位);「路过」按输入误写处理 | 用户 2026-11-10 | §1.1-⑥ / §1.2 / §9 R-1 |
+| **D2** | ✅ **已确认(按推荐①)** | **不静默补菜** + 「当前 N 菜 1 汤 / 未达标准」只读提示;删除与补菜解耦、可回退 | 用户 2026-11-10 | §1.1-⑥ / §1.2 / §9 R-2 |
+| **D3** | ✅ 已确认 | 早餐维持现状,规则仅 `lunch` / `dinner`;产品推荐 + 架构师确认生效 | — | §1.1-④ / §1.2 |
+| **D4** | ✅ 已确认 | 汤恒 1、不计入菜数、只加菜不加汤;`D(N)=4+max(0,N−3)`;产品推荐 + 架构师确认生效 | — | §1.1-④ / §1.2 |
+| **D5** | ✅ 已裁定生效 | 原 PRD 标注「最终由架构师裁定」→ 架构师裁定**推荐①**:后端按规则补齐到 `D(N)+1`,**不整份重试 AI**,异常兜底保留「提示重新生成」 | — | §1.1-④ / §1.2 |
+| **D6** | ✅ **已确认** | **① 共餐人口径**:N 以本次所选 `participant_member_ids` 为准、`meal_config` 仅兜底;**④ 隔离粒度**:家庭总览固定单键 `family`(不采用按共餐人组合留存);技术方案见架构文档 §1.1-①,PRD 不写入产品规则 | 用户 2026-11-10 | §1.1-①③ / §1.2 / §9 R-3 |
+| 附:存量回填 | ✅ **已确认(不回填)** | 存量 `view_key` 保持 NULL,老客户端不传参仍走原查询;升级当天各视角空态需重新生成一次(AC-04 允许),须写入发布说明 | 用户 2026-11-10 | §9 R-4 / R-7 |
+| 附:R-03 范围 | ✅ **已确认纳入本轮** | 「重新生成」须真正调 AI / 降级 / 归一化,不再返回空菜单;接受 `completed → pending`、不再保留历史版本行等副作用(见第七节第 7 条) | 用户 2026-11-10 | §1.1-⑤ / §9 R-5 |
+
+**G-6 门禁状态:🟢 已关闭(2026-11-10)。** 用户确认「按推荐来」,**5 项阻断项全部按推荐方案裁定**:D1 替换→删除、D2 不静默补菜、隔离粒度固定 `family`、存量不回填、R-03 纳入本轮;D3 / D4 / D5 / D6① 此前已确认。**T1~T5 可开工。**「路过」仍按输入 / 语音误写处理,若用户另有本意可覆写 D1。
+
+---
+
+## 七、不在本次范围
+
+| # | 明确排除项 |
+|---|-----------|
+| 1 | 任何代码实施:前端 / 后端 / AI 服务 / 测试 / 数据库迁移 / 接口登记(本次仅产出本文档) |
+| 2 | **早餐菜数标准**调整(D3 推荐维持现状) |
+| 3 | 营养算法与营养汇总精度(现 `aggregateNutrition` 仅聚合热量,`protein/carbs/fat` 恒 0 的问题不在本轮) |
+| 4 | 食材推荐指数算法、健康问卷调整逻辑、4 Tab 食材筛选规则(适合 / 戒备 / 全部、分类、搜索、分页) |
+| 5 | 家庭总览**分段聚合口径本身的修改**(CR-01 仅为口径确认 + 回归) |
+| 6 | 「👍 好吃 / ✕ 跳过」的收藏、评分、反馈等业务逻辑(现为空实现,本轮按 D1 推荐下线,不补业务) |
+| 7 | 「重新生成」后端不真正调用 AI 的修复(R-03):**✅ 用户已确认纳入本轮,本项已从「不在本次范围」移出**(依据:诉求 3 原话、4.3 三条生成路径、AC-27、G-3;见 1.6 R-03、6.1)。实现须真正走 AI / 降级 / `normalizeMenu` 归一化,不再返回空菜单。**副作用(发布 / 产品说明必写)**:① 重新生成把 `completed` 重置为 `pending`(「标记完成」按钮显隐恢复);② 不再保留历史版本行(覆盖同视角记录);③ 升级当天各视角首次进入为空态、需重新生成一次(存量不回填,见 6.1) |
+| 8 | 会员 / 付费 / 分销 / 商城 / 报告等其他页面与模块 |
+| 9 | 数据隔离方案、幂等键设计、表结构变更、新增接口的具体技术方案(由架构师裁定) |
+| 10 | 竞品分析、市场调研(本轮为增量需求变更,非完整 PRD / 市场研究) |
+
+---
+
+## 八、验收与回归要点
+
+### 8.1 本轮验收门禁
+
+| 门禁 | 内容 |
+|------|------|
+| G-1 | CR-02(P0)全部 AC-01 … AC-10 通过:切成员 / 切总览刷新、不同成员食谱可不同、空态、按视角共餐人生成、不覆盖其他视角 |
+| G-2 | CR-03(P0)AC-11 … AC-19 通过:替换 Bug 事实被修复、删除生效且有反馈、失败不丢数据、`status` 白名单风险(R-02)已规避 |
+| G-3 | CR-04(P1)AC-20 … AC-28 通过:规则表 / 公式 / 示例表逐项可验,AI 路径 + 降级路径 + 重新生成路径均满足 |
+| G-4 | CR-05(P1)AC-33 … AC-38 通过:按钮组合(D1 裁定后)、空 / 加载 / 失败三态、防重复点击 |
+| G-5 | CR-01 基线 AC-29 … AC-32 **回归通过**(口径不得被本轮改动破坏) |
+| G-6 | D1 … D6 裁定状态已回写至 **6.1**;**🟢 门禁已关闭(2026-11-10)**——用户确认「按推荐来」,5 项全部裁定,**T1~T5 可进入开发** |
+| G-7 | 新增接口前已查 `docs/superpowers/api/API_REFERENCE.md` 4.11 / 4.63.3,无重复端点;如有新增已登记 |
+
+### 8.2 需回归的既有能力
+
+| # | 回归项 | 关注点 |
+|---|--------|--------|
+| 1 | **家庭总览分段聚合口径** | `MIN_NEGATIVE` / `AVG_POSITIVE`、四舍五入取整、0 分计入平均、`overviewScore` 优先 + `minIndexScore` 兼容、迁移 465 历史行自愈(CR-01 基线) |
+| 2 | **共餐人多选** | 家庭总览「生成食谱」先弹层、默认全选、确认按钮人数、取消清空、`participant_member_ids` 载荷(现有 `participantSelect.test.js` 契约不得破坏) |
+| 3 | **重新生成** | `regenerate()` → `/regenerate` → `loadRecommendation()` 链路;**R-03 已确认纳入本轮**(见 6.1、第七节第 7 条);须回归「真正走 AI / 降级 / 归一化、不再返回空菜单」、`completed → pending` 与「标记完成」按钮显隐、不再保留历史版本行、AC-27 菜数规则仍生效 |
+| 4 | **标记完成** | `completeRecipe()` → `/complete`,`status` 变更与「标记完成」按钮显隐 |
+| 5 | **食材 Tab 筛选** | 4 Tab(今日食谱 / 适合 / 戒备 / 全部)、分类标签、搜索、排序、`pagedFoods` 分页与 `loadMore` 触底、`visibleCount` 重置 |
+| 6 | **MemberSelector 互斥** | `switchView('family')` 清 `currentMemberId`、`memberSelectLocked` 防 `loadMembers` 回灌默认选中、`self-label="我"` |
+| 7 | **生成 loading** | `uni.showLoading({title:'正在生成食谱...'})` / `hideLoading` / 成功失败 toast |
+| 8 | **接口契约** | 统一 `@PostMapping` + `Result<T>`;`/today` 的 `date` 必填校验(`date不能为空`)不得被破坏 |
+
+### 8.3 指定回归测试文件(必须跑通 / 按需扩写)
+
+| 文件 | 用途 |
+|------|------|
+| `cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js` | 模板结构、`switchView`/`switchMember` 互斥、4 Tab、`pagedFoods`/`loadMore`、生成 loading、「回归防护:既有核心方法仍可用」段;CR-A / CR-B / CR-05 改动的主要回归面 |
+| `cfc-frontend/__tests__/pages/diet/recommendation.participantSelect.test.js` | REQ7 共餐人多选:个人视角直传 `[currentMemberId]`、家庭总览先弹层不直接生成、默认全选、载荷含 `participant_member_ids`;与 D6 / R-01 强相关 |
+| `cfc-backend/src/test/java/com/etotem/cfc/unit/ReportDietRecommendContractTest.java` | **仅回归,不视为覆盖今日食谱**(F-20):实为 `FoodRecommendService.recalculateForUser` 契约测试(正常 / 异常 / 权限流),与 `DietRecommendationService` / `/today` / `/generate` **无直接耦合**;跑通即可,**一条断言都不改**(架构师文档 §8.1) |
+| **需新增:`DietRecommendationServiceTest`(本次必需)** | 覆盖:① 菜数公式 `D(N)=4+max(0,N−3)`、`S=1`、`dish.type` 区分汤、AI 少给菜时补齐不重试(AC-20~26);② `view_key` 视角隔离与幂等键(AC-02 / AC-08);③ `/update` 删除后 **`status` 不变更**、`/today` 仍能查到(AC-18 / R-02);④ **`regenerate` 返回非空菜单**、真正走 AI / 降级 / 归一化(AC-27 / R-03;若 R-03 裁定纳入本轮则必需);⑤ 共餐人口径 `participant_member_ids` 优先(AC-07 / AC-20)。AC → 断言映射见架构师文档 §8.2 |
+| `cfc-backend/src/test/java/com/etotem/cfc/integration/service/FamilyFoodOverviewServiceTest.java`(**基线,绝对不改且必绿**) | 迁移 465 分段聚合口径(`MIN_NEGATIVE` / `AVG_POSITIVE`、四舍五入取整、0 分计入平均、`overviewScore` 优先 + `minIndexScore` 兼容、历史行自愈);CR-01 回归依据(AC-29 … AC-32)、**G-5 门禁**,本次任何任务不得修改该文件 |
+
+### 8.4 交接提示(给架构师)
+
+1. **先解 D6 / R-01**:CR-A 与 CR-C 都依赖「共餐人以谁为准」,建议作为增量任务的前置项。
+2. **R-02 必须进设计**:`/update` 的 `status="adjusted"` 与 `/today` 查询白名单冲突,是删除功能最易踩的回归坑。
+3. **R-04 方案由架构师拍板**:PRD 只约束「不同视角可有不同食谱、互不覆盖」,不限定拆分方式。
+4. **R-05 prompt 双来源(v1.1 实锤)**:`seed_system_prompts.sql` L379–409 已有启用 `menu_generate` 文案 + `prompt_service.py` 300s 缓存 + `template.format` 仅 4 占位符 → **只改 Python 模板一定不生效**;落地清单见架构师文档 §4.4(注入既有 `{constraints_text}`、DB 文案同步、清缓存),正确性由 Java 归一化兜底保证,不依赖 prompt 生效。
+5. **接口查重(v1.1 订正)**:删除复用既有 `/update`(vue 补 1 行 import,`api.js` 零改动,F-13);**`/api/meal/replace` 语义不符,明确不复用**(F-16),且 `API_REFERENCE.md` 4.59.6.3 与其实现有偏差须如实标注;`/today` / `/generate` 视角参数扩展既有载荷,**不新增端点**;若改选新增端点须先查重并登记 4.11 节(备选规范见架构师文档 §3.7)。
+6. **测试策略**:建议按 TDD 先补契约用例(AC 可直接转断言),再改实现;本 PRD 的 AC 编号可映射为能力键。**注意 F-20**:既有 `ReportDietRecommendContractTest` 只作「不破坏」门禁,须**新增** `DietRecommendationService` 单测;3 个前端测试桩需补 `updateDietRecommendation` 桩(F-21)。
+7. **G-6 门禁已关闭(2026-11-10)**:用户确认「按推荐来」,5 项全部按推荐方案裁定并已回写 6.1,T1~T5 可开工;R-03 已纳入,须按第七节第 7 条把三项副作用写入发布 / 产品说明。
+
+---
+
+> **文档结束(v1.1)。** 本文件为**需求变更说明 / 产品规则单一来源**,不含任何实现方案裁定;技术方案、影响面分析、任务分解、排期以架构师《2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md》为准。二者冲突时**以本 PRD 的产品规则与 38 条 AC 优先,架构裁定可回退**。本次修订仅编辑本 Markdown 文档,未修改任何业务代码。

+ 6 - 4
seed_system_prompts.sql

@@ -387,11 +387,12 @@ VALUES ('menu_generate', '菜单生成提示词模板', '请为{participant_coun
 {{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "烹饪方法", "nutrition": {{"calories": 200, "protein": 10, "carbs": 30, "fat": 5}}, "notes": "备注"}}]}}]}}
 
 要求:
-1. 早/午/晚各至少1-2道菜
+1. 午餐和晚餐按上方约束给出的菜数配菜(D(N)=4+max(0,N-3) 道菜 + 1 道汤,汤恒 1 且不计入菜数、只加菜不加汤),早餐维持 1-2 道
 2. 食材用量按{participant_count}人份计算
 3. 营养均衡,考虑健康目标
 4. 只用提供的食材
-5. 只返回 JSON,不要其他文字', '请为{participant_count}人生成{date}的一日三餐菜单。
+5. 每道菜用 type 字段标注 dish(菜)或 soup(汤),汤恰好 1 道且排在最后
+6. 只返回 JSON,不要其他文字', '请为{participant_count}人生成{date}的一日三餐菜单。
 
 可用食材: {foods_text}
 用餐人数: {participant_count}人
@@ -401,11 +402,12 @@ VALUES ('menu_generate', '菜单生成提示词模板', '请为{participant_coun
 {{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "烹饪方法", "nutrition": {{"calories": 200, "protein": 10, "carbs": 30, "fat": 5}}, "notes": "备注"}}]}}]}}
 
 要求:
-1. 早/午/晚各至少1-2道菜
+1. 午餐和晚餐按上方约束给出的菜数配菜(D(N)=4+max(0,N-3) 道菜 + 1 道汤,汤恒 1 且不计入菜数、只加菜不加汤),早餐维持 1-2 道
 2. 食材用量按{participant_count}人份计算
 3. 营养均衡,考虑健康目标
 4. 只用提供的食材
-5. 只返回 JSON,不要其他文字', 1, 1, 'LangGraph 提示词初始化')
+5. 每道菜用 type 字段标注 dish(菜)或 soup(汤),汤恰好 1 道且排在最后
+6. 只返回 JSON,不要其他文字', 1, 1, 'LangGraph 提示词初始化')
 ON DUPLICATE KEY UPDATE default_content = VALUES(default_content);
 
 INSERT INTO system_prompts (prompt_key, title, content, default_content, version, status, remark)

Some files were not shown because too many files changed in this diff