Sisyphus 4 месяцев назад
Родитель
Сommit
3c3027f405
1 измененных файлов с 1922 добавлено и 0 удалено
  1. 1922 0
      docs/superpowers/plans/2026-06-24-P1营养师功能实现计划.md

+ 1922 - 0
docs/superpowers/plans/2026-06-24-P1营养师功能实现计划.md

@@ -0,0 +1,1922 @@
+# P1 营养师核心功能实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 完成 P1 四大功能——PDF食材解析扩展、微量营养素食材库+RDI、购物清单生成、营养师-家庭沟通闭环——的数据库层、服务层、API层和前端交互实现。
+
+**Architecture:** 分层实现:Wave 1 建表+实体+SEED数据 → Wave 2 解析扩展+食材适宜度 → Wave 3 营养素查询+RDI计算 → Wave 4 购物清单生成逻辑+前端 → Wave 5 沟通闭环+前端
+
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / Java 8 / MySQL 8.0 / uni-app Vue 2 (小程序)
+
+**设计文档:** `docs/superpowers/specs/2026-06-24-P1营养师功能设计方案.md`
+
+---
+
+## File Structure Overview
+
+### New Files (创建)
+
+| 层 | 路径 | 说明 |
+|---|------|------|
+| DTO | `cfc-backend/.../dto/ParsedFoodSuitability.java` | PDF食材数据DTO |
+| Entity | `cfc-backend/.../entity/ReportFoodSuitability.java` | 报告食材适宜度 |
+| Entity | `cfc-backend/.../entity/UserFoodPreference.java` | 用户食材偏好 |
+| Entity | `cfc-backend/.../entity/FoodNutrient.java` | 微量营养素 |
+| Entity | `cfc-backend/.../entity/RdiStandard.java` | RDI标准 |
+| Entity | `cfc-backend/.../entity/ShoppingList.java` | 购物清单主表 |
+| Entity | `cfc-backend/.../entity/ShoppingListItem.java` | 购物清单明细 |
+| Entity | `cfc-backend/.../entity/FollowUp.java` | 随访记录 |
+| Mapper | `cfc-backend/.../mapper/ReportFoodSuitabilityMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/UserFoodPreferenceMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/FoodNutrientMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/RdiStandardMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/ShoppingListMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/ShoppingListItemMapper.java` | |
+| Mapper | `cfc-backend/.../mapper/FollowUpMapper.java` | |
+| Service | `cfc-backend/.../service/ReportFoodSuitabilityService.java` | |
+| Service | `cfc-backend/.../service/ReportFoodSuitabilityServiceInterface.java` | |
+| Service | `cfc-backend/.../service/UserFoodPreferenceService.java` | |
+| Service | `cfc-backend/.../service/FoodNutrientService.java` | |
+| Service | `cfc-backend/.../service/RdiStandardService.java` | |
+| Service | `cfc-backend/.../service/ShoppingListService.java` | |
+| Service | `cfc-backend/.../service/ShoppingListServiceInterface.java` | |
+| Service | `cfc-backend/.../service/FollowUpService.java` | |
+| Service | `cfc-backend/.../service/FollowUpServiceInterface.java` | |
+| Controller | `cfc-backend/.../controller/ShoppingListController.java` | |
+| Controller | `cfc-backend/.../controller/guide/FollowUpController.java` | |
+| Util | `cfc-backend/.../common/NutritionCalculator.java` | RDI计算工具 |
+
+### Modified Files (修改)
+
+| 文件 | 修改内容 |
+|------|---------|
+| `ParsedReportResult.java` | 新增 foodSuitability 字段 |
+| `PdfParseService.java` | 新增 parseFoodSuitability() |
+| `HealthReportController.java` | 新增食材适宜度查询端点 |
+| `DatabaseInitializer.java` | 新增7张表DDL + ALTER TABLE foods + SEED数据 |
+| `TeacherMessage.java` | 新增 sender_role, read_at, relates_to等字段 |
+| `TeacherMessageService.java` | 扩展双向发送+已读标记 |
+| `SendMessageDTO.java` | 扩展 sender_role 等 |
+
+---
+
+## Wave 1: 数据库层 + SEED 数据
+
+### Task 1: foods 表扩展 + 新Entity创建
+
+- [ ] **Step 1: 修改 DatabaseInitializer.java — foods 表扩展**
+
+在 DatabaseInitializer.java 的 DDL 区域找到 foods 表创建语句,在其后添加 ALTER TABLE:
+
+```java
+// 在 DatabaseInitializer.run() 的 foods 表创建之后添加
+jdbcTemplate.execute("ALTER TABLE foods "
+    + "ADD COLUMN IF NOT EXISTS energy_kj DECIMAL(8,2) COMMENT '能量(kJ/100g)' AFTER calories, "
+    + "ADD COLUMN IF NOT EXISTS starch DECIMAL(8,2) COMMENT '淀粉(g/100g)' AFTER fiber, "
+    + "ADD COLUMN IF NOT EXISTS cholesterol DECIMAL(8,2) COMMENT '胆固醇(mg/100g)' AFTER starch");
+```
+
+> 注意:MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,需要用存储过程或先检查列是否存在。改用 JDBC `DatabaseMetaData` 检查列是否存在后再执行 ALTER。
+
+检查列是否存在的方法:
+```java
+private boolean columnExists(JdbcTemplate jdbcTemplate, String table, String column) {
+    List<Map<String, Object>> rows = jdbcTemplate.queryForList(
+        "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
+        table, column);
+    return !rows.isEmpty();
+}
+```
+
+然后:
+```java
+if (!columnExists(jdbcTemplate, "foods", "energy_kj")) {
+    jdbcTemplate.execute("ALTER TABLE foods ADD COLUMN energy_kj DECIMAL(8,2) ...");
+}
+```
+
+- [ ] **Step 2: 创建 Entity — ReportFoodSuitability.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import java.util.Date;
+
+@TableName("report_food_suitability")
+public class ReportFoodSuitability {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long reportId;
+    private Long foodId;
+    private String foodName;
+    private String category;
+    private Integer score;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createdAt;
+
+    // getters/setters — 用 @Data 或 Lombok,无则手动生成
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    // ... 手动生成所有 getter/setter
+}
+```
+
+- [ ] **Step 3: 创建 Entity — UserFoodPreference.java**
+
+```java
+package com.etotem.cfc.entity;
+
+@TableName("user_food_preference")
+public class UserFoodPreference {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long foodId;
+    private String foodName;
+    private Integer score;
+    private String reason;
+    private Date createdAt;
+    private Date updatedAt;
+    // getters/setters
+}
+```
+
+- [ ] **Step 4: 创建 Entity — FoodNutrient.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@TableName("food_nutrients")
+public class FoodNutrient {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long foodId;
+    private String nutrientCode;
+    private BigDecimal amountPer100g;
+    private String unit;
+    private String source;
+    private Date createdAt;
+    private Date updatedAt;
+    // getters/setters
+}
+```
+
+- [ ] **Step 5: 创建 Entity — RdiStandard.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@TableName("rdi_standards")
+public class RdiStandard {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String nutrientCode;
+    private String ageGroup;
+    private String gender;
+    private String rdiType;
+    private BigDecimal amount;
+    private String unit;
+    private String source;
+    private Date createdAt;
+    // getters/setters
+}
+```
+
+- [ ] **Step 6: 创建 Entity — ShoppingList.java / ShoppingListItem.java**
+
+**ShoppingList.java:**
+```java
+@TableName("shopping_lists")
+public class ShoppingList {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long childId;
+    private Long reportId;
+    private String title;
+    private String source;      // report/deficiency/manual/mixed
+    private String status;      // active/completed/archived
+    private Integer totalItems;
+    private Integer purchasedItems;
+    private Date createdAt;
+    private Date updatedAt;
+    // getters/setters
+}
+```
+
+**ShoppingListItem.java:**
+```java
+@TableName("shopping_list_items")
+public class ShoppingListItem {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long listId;
+    private Long foodId;
+    private String foodName;
+    private String category;
+    private BigDecimal quantity;
+    private String unit;
+    private String priority;    // high/medium/low
+    private String reason;
+    private Integer score;
+    private String source;      // report/deficiency/manual
+    private Boolean purchased;
+    private Date purchasedAt;
+    private Integer sortOrder;
+    private Date createdAt;
+    // getters/setters
+}
+```
+
+- [ ] **Step 7: 创建 Entity — FollowUp.java**
+
+```java
+@TableName("follow_up")
+public class FollowUp {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long familyId;
+    private Long childId;
+    private Long teacherId;
+    private Long deficiencyId;
+    private Long messageId;
+    private String status;      // pending/in_progress/completed/ignored
+    private String priority;    // high/medium/low
+    private String description;
+    private String parentFeedback;
+    private String teacherReview;
+    private Date dueDate;
+    private Date completedAt;
+    private Date createdAt;
+    private Date updatedAt;
+    // getters/setters
+}
+```
+
+- [ ] **Step 8: DatabaseInitializer.java — 新建表DDL**
+
+在 DatabaseInitializer.run() 中添加7张表的 CREATE TABLE(按依赖顺序:先 foods 扩展,再独立表):
+
+```java
+// report_food_suitability
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_food_suitability ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "report_id BIGINT NOT NULL, "
+    + "food_id BIGINT, "
+    + "food_name VARCHAR(100) NOT NULL, "
+    + "category VARCHAR(50), "
+    + "score INT NOT NULL, "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "INDEX idx_report (report_id), "
+    + "INDEX idx_food (food_id)"
+    + ")");
+
+// user_food_preference
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_food_preference ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "user_id BIGINT NOT NULL, "
+    + "food_id BIGINT, "
+    + "food_name VARCHAR(100) NOT NULL, "
+    + "score INT NOT NULL DEFAULT 0, "
+    + "reason VARCHAR(500), "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+    + "INDEX idx_user (user_id)"
+    + ")");
+
+// food_nutrients
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS food_nutrients ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "food_id BIGINT NOT NULL, "
+    + "nutrient_code VARCHAR(20) NOT NULL, "
+    + "amount_per_100g DECIMAL(10,4) NOT NULL, "
+    + "unit VARCHAR(20) NOT NULL, "
+    + "source VARCHAR(50) DEFAULT 'importer', "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+    + "UNIQUE KEY uk_food_nutrient (food_id, nutrient_code), "
+    + "INDEX idx_nutrient (nutrient_code)"
+    + ")");
+
+// rdi_standards
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS rdi_standards ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "nutrient_code VARCHAR(20) NOT NULL, "
+    + "age_group VARCHAR(10) NOT NULL, "
+    + "gender VARCHAR(6) DEFAULT 'all', "
+    + "rdi_type VARCHAR(10) NOT NULL, "
+    + "amount DECIMAL(10,4) NOT NULL, "
+    + "unit VARCHAR(20) NOT NULL, "
+    + "source VARCHAR(100), "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "UNIQUE KEY uk_rdi (nutrient_code, age_group, gender, rdi_type), "
+    + "INDEX idx_age (age_group)"
+    + ")");
+
+// shopping_lists
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shopping_lists ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "user_id BIGINT NOT NULL, "
+    + "child_id BIGINT, "
+    + "report_id BIGINT, "
+    + "title VARCHAR(200), "
+    + "source VARCHAR(20) NOT NULL, "
+    + "status VARCHAR(20) DEFAULT 'active', "
+    + "total_items INT DEFAULT 0, "
+    + "purchased_items INT DEFAULT 0, "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+    + "INDEX idx_user (user_id), "
+    + "INDEX idx_report (report_id), "
+    + "INDEX idx_status (status)"
+    + ")");
+
+// shopping_list_items
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS shopping_list_items ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "list_id BIGINT NOT NULL, "
+    + "food_id BIGINT, "
+    + "food_name VARCHAR(100) NOT NULL, "
+    + "category VARCHAR(50), "
+    + "quantity DECIMAL(8,2) DEFAULT 1, "
+    + "unit VARCHAR(20) DEFAULT '份', "
+    + "priority VARCHAR(10) DEFAULT 'medium', "
+    + "reason VARCHAR(200), "
+    + "score INT, "
+    + "source VARCHAR(20), "
+    + "purchased TINYINT DEFAULT 0, "
+    + "purchased_at DATETIME, "
+    + "sort_order INT DEFAULT 0, "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "INDEX idx_list (list_id), "
+    + "INDEX idx_purchased (purchased)"
+    + ")");
+
+// follow_up
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS follow_up ("
+    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+    + "family_id BIGINT NOT NULL, "
+    + "child_id BIGINT, "
+    + "teacher_id BIGINT, "
+    + "deficiency_id BIGINT, "
+    + "message_id BIGINT, "
+    + "status VARCHAR(20) DEFAULT 'pending', "
+    + "priority VARCHAR(10) DEFAULT 'medium', "
+    + "description VARCHAR(500), "
+    + "parent_feedback TEXT, "
+    + "teacher_review VARCHAR(500), "
+    + "due_date DATETIME, "
+    + "completed_at DATETIME, "
+    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+    + "INDEX idx_family (family_id), "
+    + "INDEX idx_teacher (teacher_id), "
+    + "INDEX idx_status (status), "
+    + "INDEX idx_due (due_date)"
+    + ")");
+```
+
+- [ ] **Step 9: 创建 7 个 Mapper 文件(全部 BaseMapper 继承)**
+
+每个 mapper 格式如下,以 ReportFoodSuitabilityMapper.java 为例:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ReportFoodSuitability;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ReportFoodSuitabilityMapper extends BaseMapper<ReportFoodSuitability> {
+}
+```
+
+同样的模式创建:
+- UserFoodPreferenceMapper.java
+- FoodNutrientMapper.java
+- RdiStandardMapper.java
+- ShoppingListMapper.java
+- ShoppingListItemMapper.java
+- FollowUpMapper.java
+
+- [ ] **Step 10: 验证编译通过**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS (or 无编译错误)
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFoodSuitability.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/UserFoodPreference.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FoodNutrient.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/RdiStandard.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ShoppingList.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ShoppingListItem.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FollowUp.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/*Mapper.java
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat(p1): Wave1 数据库层 — 7张新表+实体+Mapper"
+```
+
+---
+
+## Wave 2: PDF 解析扩展 + 食材适宜度
+
+### Task 2.1: ParsedFoodSuitability DTO + ParsedReportResult 扩展
+
+- [ ] **Step 1: 创建 ParsedFoodSuitability.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import java.math.BigDecimal;
+
+public class ParsedFoodSuitability {
+    private String foodName;
+    private String category;
+    private Integer score;           // 推荐指数 -100~+100
+    private BigDecimal energyKj;
+    private BigDecimal protein;
+    private BigDecimal fat;
+    private BigDecimal carbs;
+    private BigDecimal starch;
+    private BigDecimal fiber;
+    private BigDecimal cholesterol;
+
+    // getters/setters
+    public String getFoodName() { return foodName; }
+    public void setFoodName(String foodName) { this.foodName = foodName; }
+    // ... 剩余 getter/setter
+}
+```
+
+- [ ] **Step 2: 修改 ParsedReportResult.java — 新增字段**
+
+找到文件末尾 getter/setter 之前,添加:
+```java
+private List<ParsedFoodSuitability> foodSuitability;
+
+public List<ParsedFoodSuitability> getFoodSuitability() { return foodSuitability; }
+public void setFoodSuitability(List<ParsedFoodSuitability> foodSuitability) { this.foodSuitability = foodSuitability; }
+```
+
+- [ ] **Step 3: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedFoodSuitability.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java
+git commit -m "feat(p1): Wave2.1 — ParsedFoodSuitability DTO"
+```
+
+### Task 2.2: PdfParseService 扩展 — parseFoodSuitability()
+
+- [ ] **Step 1: 在 PdfParseService.java 中添加 parseFoodSuitability()**
+
+```java
+/**
+ * 解析PDF中的食材推荐章节
+ * PDF格式:每行一个食材,格式为: 食材名称,分类,推荐指数,能量KJ,蛋白质g,脂肪g,碳水化合物g,淀粉g,膳食纤维g,胆固醇mg
+ */
+public List<ParsedFoodSuitability> parseFoodSuitability() {
+    List<ParsedFoodSuitability> result = new ArrayList<>();
+    // 定位食材章节
+    String foodSection = findSection("食材推荐", "食材评分", "食物适宜度");
+    if (foodSection == null || foodSection.trim().isEmpty()) {
+        return result; // 空列表,非错误
+    }
+
+    String[] lines = foodSection.split("\n");
+    for (String line : lines) {
+        line = line.trim();
+        if (line.isEmpty()) continue;
+        String[] parts = line.split(",");
+        if (parts.length < 4) continue; // 最少要有名称+分类+分数
+
+        try {
+            ParsedFoodSuitability item = new ParsedFoodSuitability();
+            item.setFoodName(parts[0].trim());
+            item.setCategory(parts[1].trim());
+            item.setScore(Integer.parseInt(parts[2].trim()));
+
+            // 后续字段可选
+            if (parts.length > 3) item.setEnergyKj(new BigDecimal(parts[3].trim()));
+            if (parts.length > 4) item.setProtein(new BigDecimal(parts[4].trim()));
+            if (parts.length > 5) item.setFat(new BigDecimal(parts[5].trim()));
+            if (parts.length > 6) item.setCarbs(new BigDecimal(parts[6].trim()));
+            if (parts.length > 7) item.setStarch(new BigDecimal(parts[7].trim()));
+            if (parts.length > 8) item.setFiber(new BigDecimal(parts[8].trim()));
+            if (parts.length > 9) item.setCholesterol(new BigDecimal(parts[9].trim()));
+
+            result.add(item);
+        } catch (NumberFormatException e) {
+            // 跳过解析失败的食材行
+            log.warn("Failed to parse food suitability line: {}", line);
+        }
+    }
+    return result;
+}
+
+/** 查找PDF中包含指定关键词的章节内容 */
+private String findSection(String... keywords) {
+    // 遍历 PDF 文本行,找到第一个包含任一关键词的区域
+    // 返回其后续行直到下一个章节标题
+    // 实现取决于 PDF 文本提取方式(如 PDFBox、iText 等)
+    return null; // 占位,需要根据实际PDF解析库实现
+}
+```
+
+> 注意:实际实现需要根据 PdfParseService 当前使用的 PDF 解析库(如 PDFBox/iText)来实现文本区域定位。上述代码为逻辑框架,具体 findSection 方法需适配现有解析流程。
+
+- [ ] **Step 2: 修改 parse() 主方法,在解析流程末尾调用 parseFoodSuitability()**
+
+```java
+// 在 PdfParseService.parse() 方法的 return 之前添加
+parsedResult.setFoodSuitability(parseFoodSuitability());
+```
+
+- [ ] **Step 3: 修改 HealthReportService 的保存逻辑,将解析出的食材数据持久化**
+
+在 HealthReportService.createReport() 或对应的 PDF 保存方法中,解析完成后:
+```java
+// 在 report 保存之后,遍历 foodSuitability
+List<ParsedFoodSuitability> foods = parsedResult.getFoodSuitability();
+if (foods != null && !foods.isEmpty()) {
+    for (ParsedFoodSuitability pf : foods) {
+        ReportFoodSuitability entity = new ReportFoodSuitability();
+        entity.setReportId(reportId);
+        entity.setFoodName(pf.getFoodName());
+        entity.setCategory(pf.getCategory());
+        entity.setScore(pf.getScore());
+
+        // 匹配 foods 表
+        Food matchedFood = foodService.findByName(pf.getFoodName());
+        if (matchedFood != null) {
+            entity.setFoodId(matchedFood.getId());
+            // 首次采集:更新食物的营养数据(如果缺失)
+            updateFoodNutritionIfNeeded(matchedFood, pf);
+        }
+
+        reportFoodSuitabilityService.save(entity);
+    }
+}
+```
+
+- [ ] **Step 4: 实现 updateFoodNutritionIfNeeded()**
+
+```java
+private void updateFoodNutritionIfNeeded(Food food, ParsedFoodSuitability pf) {
+    boolean needsUpdate = false;
+    if (pf.getEnergyKj() != null && food.getEnergyKj() == null) {
+        food.setEnergyKj(pf.getEnergyKj());
+        needsUpdate = true;
+    }
+    if (pf.getStarch() != null && food.getStarch() == null) {
+        food.setStarch(pf.getStarch());
+        needsUpdate = true;
+    }
+    if (pf.getCholesterol() != null && food.getCholesterol() == null) {
+        food.setCholesterol(pf.getCholesterol());
+        needsUpdate = true;
+    }
+    if (needsUpdate) {
+        foodService.update(food);
+    }
+}
+```
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
+git commit -m "feat(p1): Wave2.2 — PdfParseService 食材解析扩展"
+```
+
+### Task 2.3: 食材适宜度 Service 层 + Controller 端点
+
+- [ ] **Step 1: 创建 ReportFoodSuitabilityServiceInterface.java**
+
+```java
+package com.etotem.cfc.service.api;
+
+import com.etotem.cfc.entity.ReportFoodSuitability;
+import java.util.List;
+
+public interface ReportFoodSuitabilityServiceInterface {
+    List<ReportFoodSuitability> getByReportId(Long reportId);
+    List<ReportFoodSuitability> getByReportIdWithScoreAbove(Long reportId, int minScore);
+    ReportFoodSuitability save(ReportFoodSuitability entity);
+    List<ReportFoodSuitability> getHistoryByFoodId(Long foodId);
+}
+```
+
+- [ ] **Step 2: 创建 ReportFoodSuitabilityService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ReportFoodSuitability;
+import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
+import com.etotem.cfc.service.api.ReportFoodSuitabilityServiceInterface;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class ReportFoodSuitabilityService implements ReportFoodSuitabilityServiceInterface {
+
+    @Resource
+    private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
+
+    @Override
+    public List<ReportFoodSuitability> getByReportId(Long reportId) {
+        LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ReportFoodSuitability::getReportId, reportId);
+        wrapper.orderByDesc(ReportFoodSuitability::getScore);
+        return reportFoodSuitabilityMapper.selectList(wrapper);
+    }
+
+    @Override
+    public List<ReportFoodSuitability> getByReportIdWithScoreAbove(Long reportId, int minScore) {
+        LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ReportFoodSuitability::getReportId, reportId);
+        wrapper.ge(ReportFoodSuitability::getScore, minScore);
+        wrapper.orderByDesc(ReportFoodSuitability::getScore);
+        return reportFoodSuitabilityMapper.selectList(wrapper);
+    }
+
+    @Override
+    public ReportFoodSuitability save(ReportFoodSuitability entity) {
+        reportFoodSuitabilityMapper.insert(entity);
+        return entity;
+    }
+
+    @Override
+    public List<ReportFoodSuitability> getHistoryByFoodId(Long foodId) {
+        LambdaQueryWrapper<ReportFoodSuitability> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ReportFoodSuitability::getFoodId, foodId);
+        wrapper.orderByDesc(ReportFoodSuitability::getCreatedAt);
+        return reportFoodSuitabilityMapper.selectList(wrapper);
+    }
+}
+```
+
+- [ ] **Step 3: 创建 UserFoodPreferenceService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.UserFoodPreference;
+import com.etotem.cfc.mapper.UserFoodPreferenceMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class UserFoodPreferenceService {
+
+    @Resource
+    private UserFoodPreferenceMapper userFoodPreferenceMapper;
+
+    public List<UserFoodPreference> getByUserId(Long userId) {
+        LambdaQueryWrapper<UserFoodPreference> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(UserFoodPreference::getUserId, userId);
+        return userFoodPreferenceMapper.selectList(wrapper);
+    }
+
+    public UserFoodPreference save(UserFoodPreference preference) {
+        // UPSERT: 同一用户+食材只保留一条
+        LambdaQueryWrapper<UserFoodPreference> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(UserFoodPreference::getUserId, preference.getUserId());
+        wrapper.eq(UserFoodPreference::getFoodId, preference.getFoodId());
+        UserFoodPreference existing = userFoodPreferenceMapper.selectOne(wrapper);
+        if (existing != null) {
+            preference.setId(existing.getId());
+            userFoodPreferenceMapper.updateById(preference);
+        } else {
+            userFoodPreferenceMapper.insert(preference);
+        }
+        return preference;
+    }
+
+    public void delete(Long id) {
+        userFoodPreferenceMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 4: 在 HealthReportController 中新増食材适宜度查询端点**
+
+```java
+// POST /api/health/report/{reportId}/food-suitability
+@PostMapping("/report/{reportId}/food-suitability")
+public Result<List<ReportFoodSuitability>> getFoodSuitability(
+        @PathVariable Long reportId) {
+    List<ReportFoodSuitability> list =
+        reportFoodSuitabilityService.getByReportId(reportId);
+    return Result.success(list);
+}
+```
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/ReportFoodSuitabilityServiceInterface.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportFoodSuitabilityService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/UserFoodPreferenceService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
+git commit -m "feat(p1): Wave2.3 — 食材适宜度Service+Controller"
+```
+
+---
+
+## Wave 3: 微量营养素查询 + RDI 计算
+
+### Task 3.1: FoodNutrientService + RdiStandardService
+
+- [ ] **Step 1: 创建 FoodNutrientService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FoodNutrient;
+import com.etotem.cfc.mapper.FoodNutrientMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class FoodNutrientService {
+
+    @Resource
+    private FoodNutrientMapper foodNutrientMapper;
+
+    /** 获取某食材的所有微量营养素 */
+    public List<FoodNutrient> getByFoodId(Long foodId) {
+        LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FoodNutrient::getFoodId, foodId);
+        return foodNutrientMapper.selectList(wrapper);
+    }
+
+    /** 按营养素编码查询含量最高的食材 */
+    public List<FoodNutrient> getTopFoodsByNutrient(String nutrientCode, int limit) {
+        LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FoodNutrient::getNutrientCode, nutrientCode);
+        wrapper.orderByDesc(FoodNutrient::getAmountPer100g);
+        wrapper.last("LIMIT " + limit);
+        return foodNutrientMapper.selectList(wrapper);
+    }
+
+    /** 批量保存(用于PDF首次解析后的数据导入) */
+    public void batchSave(List<FoodNutrient> nutrients) {
+        nutrients.forEach(n -> {
+            LambdaQueryWrapper<FoodNutrient> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(FoodNutrient::getFoodId, n.getFoodId());
+            wrapper.eq(FoodNutrient::getNutrientCode, n.getNutrientCode());
+            FoodNutrient existing = foodNutrientMapper.selectOne(wrapper);
+            if (existing == null) {
+                foodNutrientMapper.insert(n);
+            } else {
+                n.setId(existing.getId());
+                foodNutrientMapper.updateById(n);
+            }
+        });
+    }
+}
+```
+
+- [ ] **Step 2: 创建 RdiStandardService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.RdiStandard;
+import com.etotem.cfc.mapper.RdiStandardMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class RdiStandardService {
+
+    @Resource
+    private RdiStandardMapper rdiStandardMapper;
+
+    /** 查询某年龄段+性别+营养素的推荐摄入量 */
+    public RdiStandard getRdi(String nutrientCode, String ageGroup, String gender) {
+        LambdaQueryWrapper<RdiStandard> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(RdiStandard::getNutrientCode, nutrientCode);
+        wrapper.eq(RdiStandard::getAgeGroup, ageGroup);
+        wrapper.and(w -> w.eq(RdiStandard::getGender, gender)
+                         .or().eq(RdiStandard::getGender, "all"));
+        wrapper.eq(RdiStandard::getRdiType, "RNI");
+        return rdiStandardMapper.selectOne(wrapper);
+    }
+
+    /** 查询UL上限值 */
+    public RdiStandard getUl(String nutrientCode, String ageGroup, String gender) {
+        LambdaQueryWrapper<RdiStandard> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(RdiStandard::getNutrientCode, nutrientCode);
+        wrapper.eq(RdiStandard::getAgeGroup, ageGroup);
+        wrapper.and(w -> w.eq(RdiStandard::getGender, gender)
+                         .or().eq(RdiStandard::getGender, "all"));
+        wrapper.eq(RdiStandard::getRdiType, "UL");
+        return rdiStandardMapper.selectOne(wrapper);
+    }
+}
+```
+
+- [ ] **Step 3: 创建 NutritionCalculator.java**
+
+```java
+package com.etotem.cfc.common;
+
+import com.etotem.cfc.entity.RdiStandard;
+import com.etotem.cfc.service.RdiStandardService;
+import org.springframework.stereotype.Component;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+@Component
+public class NutritionCalculator {
+
+    @Resource
+    private RdiStandardService rdiStandardService;
+
+    /**
+     * 计算某食物某营养素占RDI的百分比
+     * @param amountPer100g 每100g含量
+     * @param nutrientCode 营养素编码
+     * @param ageGroup 年龄段
+     * @param gender 性别
+     * @param servingSizeGrams 实际食用克数
+     * @return 百分比(如 0.85 表示85%)
+     */
+    public BigDecimal calculateRdiPercentage(BigDecimal amountPer100g,
+                                              String nutrientCode,
+                                              String ageGroup,
+                                              String gender,
+                                              BigDecimal servingSizeGrams) {
+        if (amountPer100g == null || servingSizeGrams == null) {
+            return BigDecimal.ZERO;
+        }
+        RdiStandard rdi = rdiStandardService.getRdi(nutrientCode, ageGroup, gender);
+        if (rdi == null || rdi.getAmount() == null) {
+            return null; // 无RDI标准
+        }
+        // 实际摄入 = 每100g含量 * (食用量/100)
+        BigDecimal actualIntake = amountPer100g
+            .multiply(servingSizeGrams)
+            .divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
+        // 占RDI百分比
+        return actualIntake.divide(rdi.getAmount(), 4, RoundingMode.HALF_UP);
+    }
+}
+```
+
+- [ ] **Step 4: DatabaseInitializer.java — SEED RDI 参考数据**
+
+在 run() 方法末尾添加 RDI seed 数据(示例:部分数据,完整数据需参考 DRIs 2023 手册):
+
+```java
+// 仅当 rdi_standards 表为空时插入 seed
+Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM rdi_standards", Integer.class);
+if (count == null || count == 0) {
+    Object[][] rdiData = {
+        // nutrient_code, age_group, gender, rdi_type, amount, unit
+        {"IRON", "1-3", "all", "RNI", 9.0, "mg"},
+        {"IRON", "4-6", "all", "RNI", 10.0, "mg"},
+        {"IRON", "7-10", "all", "RNI", 13.0, "mg"},
+        {"IRON", "11-13", "male", "RNI", 15.0, "mg"},
+        {"IRON", "11-13", "female", "RNI", 18.0, "mg"},
+        {"IRON", "14-17", "male", "RNI", 16.0, "mg"},
+        {"IRON", "14-17", "female", "RNI", 20.0, "mg"},
+        {"CALCIUM", "1-3", "all", "RNI", 600.0, "mg"},
+        {"CALCIUM", "4-6", "all", "RNI", 800.0, "mg"},
+        {"CALCIUM", "7-10", "all", "RNI", 1000.0, "mg"},
+        {"CALCIUM", "11-13", "all", "RNI", 1200.0, "mg"},
+        {"CALCIUM", "14-17", "all", "RNI", 1000.0, "mg"},
+        {"ZINC", "1-3", "all", "RNI", 4.0, "mg"},
+        {"ZINC", "4-6", "all", "RNI", 5.5, "mg"},
+        {"ZINC", "7-10", "all", "RNI", 7.0, "mg"},
+        {"ZINC", "11-13", "male", "RNI", 10.0, "mg"},
+        {"ZINC", "11-13", "female", "RNI", 9.0, "mg"},
+        {"VIT_D", "1-17", "all", "RNI", 10.0, "μg"},
+        {"VIT_A", "1-3", "all", "RNI", 310.0, "μg"},
+        {"VIT_A", "4-6", "all", "RNI", 360.0, "μg"},
+        {"VIT_A", "7-10", "all", "RNI", 500.0, "μg"},
+        {"VIT_A", "11-13", "male", "RNI", 670.0, "μg"},
+        {"VIT_A", "11-13", "female", "RNI", 630.0, "μg"},
+        {"VIT_C", "1-3", "all", "RNI", 40.0, "mg"},
+        {"VIT_C", "4-6", "all", "RNI", 50.0, "mg"},
+        {"VIT_C", "7-10", "all", "RNI", 65.0, "mg"},
+        {"VIT_C", "11-13", "all", "RNI", 90.0, "mg"},
+        {"VIT_C", "14-17", "all", "RNI", 100.0, "mg"},
+        // ... 完整 DRIs 2023 数据
+    };
+    for (Object[] row : rdiData) {
+        jdbcTemplate.update(
+            "INSERT IGNORE INTO rdi_standards (nutrient_code, age_group, gender, rdi_type, amount, unit, source) VALUES (?, ?, ?, ?, ?, ?, 'DRIs2023')",
+            row[0], row[1], row[2], row[3], row[4], row[5]
+        );
+    }
+}
+```
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FoodNutrientService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RdiStandardService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/common/NutritionCalculator.java
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat(p1): Wave3 — FoodNutrientService+RdiStandardService+NutritionCalculator"
+```
+
+---
+
+## Wave 4: 购物清单生成
+
+### Task 4.1: ShoppingListService — 生成逻辑 + Controller
+
+- [ ] **Step 1: 创建 ShoppingListServiceInterface.java**
+
+```java
+package com.etotem.cfc.service.api;
+
+import com.etotem.cfc.entity.ShoppingList;
+import com.etotem.cfc.entity.ShoppingListItem;
+import java.util.List;
+
+public interface ShoppingListServiceInterface {
+    /** 基于菌群报告生成购物清单 */
+    ShoppingList createFromReport(Long userId, Long childId, Long reportId);
+    /** 基于缺乏分析生成购物清单 */
+    ShoppingList createFromDeficiency(Long userId, Long childId, Long deficiencyId);
+    /** 获取用户的活跃清单 */
+    List<ShoppingList> getUserActiveLists(Long userId);
+    /** 标记已购 */
+    void markItemPurchased(Long itemId);
+    /** 手动添加食材 */
+    ShoppingListItem addItemManually(Long listId, Long foodId, String foodName, Integer score, String reason);
+    /** 归档清单 */
+    void archiveList(Long listId);
+}
+```
+
+- [ ] **Step 2: 创建 ShoppingListService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.ShoppingListMapper;
+import com.etotem.cfc.mapper.ShoppingListItemMapper;
+import com.etotem.cfc.service.api.ShoppingListServiceInterface;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class ShoppingListService implements ShoppingListServiceInterface {
+
+    @Resource
+    private ShoppingListMapper shoppingListMapper;
+    @Resource
+    private ShoppingListItemMapper shoppingListItemMapper;
+    @Resource
+    private ReportFoodSuitabilityService reportFoodSuitabilityService;
+
+    @Override
+    @Transactional
+    public ShoppingList createFromReport(Long userId, Long childId, Long reportId) {
+        // 1. 创建清单主记录
+        ShoppingList list = new ShoppingList();
+        list.setUserId(userId);
+        list.setChildId(childId);
+        list.setReportId(reportId);
+        list.setTitle("营养购物清单");
+        list.setSource("report");
+        list.setStatus("active");
+        list.setTotalItems(0);
+        list.setPurchasedItems(0);
+        shoppingListMapper.insert(list);
+
+        // 2. 从报告食材适宜度取 score >= 50 的食材
+        List<ReportFoodSuitability> foods = reportFoodSuitabilityService
+            .getByReportIdWithScoreAbove(reportId, 50);
+
+        int itemCount = 0;
+        for (ReportFoodSuitability rf : foods) {
+            ShoppingListItem item = new ShoppingListItem();
+            item.setListId(list.getId());
+            item.setFoodId(rf.getFoodId());
+            item.setFoodName(rf.getFoodName());
+            item.setCategory(rf.getCategory());
+            item.setQuantity(BigDecimal.ONE);
+            item.setUnit("份");
+            item.setPriority(rf.getScore() >= 80 ? "high" : "medium");
+            item.setReason("菌群报告推荐指数 " + rf.getScore());
+            item.setScore(rf.getScore());
+            item.setSource("report");
+            item.setPurchased(false);
+            item.setSortOrder(-rf.getScore()); // score越高越靠前
+            shoppingListItemMapper.insert(item);
+            itemCount++;
+        }
+
+        // 3. 更新总数
+        list.setTotalItems(itemCount);
+        shoppingListMapper.updateById(list);
+        return list;
+    }
+
+    @Override
+    @Transactional
+    public ShoppingList createFromDeficiency(Long userId, Long childId, Long deficiencyId) {
+        // 简化:创建清单 + 根据缺乏分析推荐食材
+        ShoppingList list = new ShoppingList();
+        list.setUserId(userId);
+        list.setChildId(childId);
+        list.setSource("deficiency");
+        list.setTitle("营养补充购物清单");
+        list.setStatus("active");
+        shoppingListMapper.insert(list);
+        // 具体缺乏分析→食材映射逻辑(整合 P0 NutritionDeficiencyService)
+        // 暂留 TODO,待集成时完善
+        return list;
+    }
+
+    @Override
+    public List<ShoppingList> getUserActiveLists(Long userId) {
+        LambdaQueryWrapper<ShoppingList> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ShoppingList::getUserId, userId);
+        wrapper.eq(ShoppingList::getStatus, "active");
+        wrapper.orderByDesc(ShoppingList::getCreatedAt);
+        return shoppingListMapper.selectList(wrapper);
+    }
+
+    @Override
+    public void markItemPurchased(Long itemId) {
+        ShoppingListItem item = shoppingListItemMapper.selectById(itemId);
+        if (item == null) return;
+        item.setPurchased(true);
+        item.setPurchasedAt(new Date());
+        shoppingListItemMapper.updateById(item);
+
+        // 更新清单已购数
+        ShoppingList list = shoppingListMapper.selectById(item.getListId());
+        if (list != null) {
+            LambdaQueryWrapper<ShoppingListItem> countWrapper = new LambdaQueryWrapper<>();
+            countWrapper.eq(ShoppingListItem::getListId, list.getId());
+            countWrapper.eq(ShoppingListItem::getPurchased, true);
+            Long purchasedCount = shoppingListItemMapper.selectCount(countWrapper);
+            list.setPurchasedItems(purchasedCount.intValue());
+            shoppingListMapper.updateById(list);
+        }
+    }
+
+    @Override
+    public ShoppingListItem addItemManually(Long listId, Long foodId, String foodName, Integer score, String reason) {
+        ShoppingListItem item = new ShoppingListItem();
+        item.setListId(listId);
+        item.setFoodId(foodId);
+        item.setFoodName(foodName);
+        item.setQuantity(BigDecimal.ONE);
+        item.setUnit("份");
+        item.setPriority(score != null && score >= 50 ? "high" : "medium");
+        item.setReason(reason);
+        item.setScore(score);
+        item.setSource("manual");
+        item.setPurchased(false);
+        shoppingListItemMapper.insert(item);
+
+        // 更新清单总数
+        ShoppingList list = shoppingListMapper.selectById(listId);
+        if (list != null) {
+            list.setTotalItems(list.getTotalItems() + 1);
+            shoppingListMapper.updateById(list);
+        }
+        return item;
+    }
+
+    @Override
+    public void archiveList(Long listId) {
+        ShoppingList list = shoppingListMapper.selectById(listId);
+        if (list != null) {
+            list.setStatus("archived");
+            shoppingListMapper.updateById(list);
+        }
+    }
+}
+```
+
+- [ ] **Step 3: 创建 ShoppingListController.java**
+
+```java
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ShoppingList;
+import com.etotem.cfc.entity.ShoppingListItem;
+import com.etotem.cfc.service.ShoppingListService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/shopping-list")
+public class ShoppingListController {
+
+    @Resource
+    private ShoppingListService shoppingListService;
+
+    @PostMapping("/create-from-report")
+    public Result<ShoppingList> createFromReport(
+            @RequestAttribute("userId") Long userId,
+            @RequestParam Long childId,
+            @RequestParam Long reportId) {
+        ShoppingList list = shoppingListService.createFromReport(userId, childId, reportId);
+        return Result.success(list);
+    }
+
+    @PostMapping("/list")
+    public Result<List<ShoppingList>> list(@RequestAttribute("userId") Long userId) {
+        return Result.success(shoppingListService.getUserActiveLists(userId));
+    }
+
+    @PostMapping("/{id}/items")
+    public Result<List<ShoppingListItem>> getItems(@PathVariable Long id) {
+        LambdaQueryWrapper<ShoppingListItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ShoppingListItem::getListId, id);
+        wrapper.orderByAsc(ShoppingListItem::getSortOrder);
+        // 需要注入 mapper
+        return Result.success(/* items */);
+    }
+
+    @PostMapping("/item/{id}/purchase")
+    public Result<Void> markPurchased(@PathVariable Long id) {
+        shoppingListService.markItemPurchased(id);
+        return Result.success();
+    }
+
+    @PostMapping("/item/add")
+    public Result<ShoppingListItem> addItem(@RequestBody AddItemRequest request,
+                                             @RequestAttribute("userId") Long userId) {
+        ShoppingListItem item = shoppingListService.addItemManually(
+            request.getListId(), request.getFoodId(), request.getFoodName(),
+            request.getScore(), request.getReason());
+        return Result.success(item);
+    }
+
+    @PostMapping("/{id}/archive")
+    public Result<Void> archive(@PathVariable Long id) {
+        shoppingListService.archiveList(id);
+        return Result.success();
+    }
+
+    // 内部 DTO
+    public static class AddItemRequest {
+        private Long listId;
+        private Long foodId;
+        private String foodName;
+        private Integer score;
+        private String reason;
+        // getters/setters
+    }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/ShoppingListServiceInterface.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ShoppingListService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ShoppingListController.java
+git commit -m "feat(p1): Wave4.1 — ShoppingListService+Controller"
+```
+
+---
+
+## Wave 5: 营养师-家庭沟通闭环
+
+### Task 5.1: TeacherMessage 扩展
+
+- [ ] **Step 1: DatabaseInitializer.java — ALTER TABLE teacher_messages**
+
+```java
+// 检查列是否存在后ALTER
+if (!columnExists(jdbcTemplate, "teacher_messages", "sender_role")) {
+    jdbcTemplate.execute("ALTER TABLE teacher_messages "
+        + "ADD COLUMN sender_role VARCHAR(20) DEFAULT 'parent' AFTER status, "
+        + "ADD COLUMN read_at DATETIME AFTER sender_role, "
+        + "ADD COLUMN relates_to_type VARCHAR(50) AFTER read_at, "
+        + "ADD COLUMN relates_to_id BIGINT AFTER relates_to_type, "
+        + "ADD COLUMN message_type VARCHAR(20) DEFAULT 'text' AFTER relates_to_id");
+}
+```
+
+- [ ] **Step 2: 修改 TeacherMessage.java** — 新增字段
+
+```java
+private String senderRole;      // parent/teacher/nutritionist
+private Date readAt;
+private String relatesToType;   // deficiency_report/shopping_list/meal_plan/general
+private Long relatesToId;
+private String messageType;     // text/recommendation/system
+// getters/setters
+```
+
+- [ ] **Step 3: 扩展 SendMessageDTO.java**
+
+```java
+public class SendMessageDTO {
+    private String content;
+    private Long familyId;
+    // 新增
+    private String senderRole;
+    private String relatesToType;
+    private Long relatesToId;
+    private String messageType;
+    // getters/setters
+}
+```
+
+- [ ] **Step 4: 扩展 TeacherMessageService.java — 双向发送 + 已读**
+
+```java
+@Override
+public Result<TeacherMessage> send(SendMessageDTO dto) {
+    TeacherMessage msg = new TeacherMessage();
+    msg.setFamilyId(dto.getFamilyId());
+    msg.setContent(dto.getContent());
+    msg.setSenderRole(dto.getSenderRole() != null ? dto.getSenderRole() : "parent");
+    msg.setStatus("pending");
+    msg.setRelatesToType(dto.getRelatesToType());
+    msg.setRelatesToId(dto.getRelatesToId());
+    msg.setMessageType(dto.getMessageType() != null ? dto.getMessageType() : "text");
+    teacherMessageMapper.insert(msg);
+    return Result.success(msg);
+}
+
+// 新增:标记已读
+@Override
+public Result<Void> markAsRead(Long messageId) {
+    TeacherMessage msg = teacherMessageMapper.selectById(messageId);
+    if (msg != null && msg.getReadAt() == null) {
+        msg.setReadAt(new Date());
+        teacherMessageMapper.updateById(msg);
+    }
+    return Result.success();
+}
+```
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/TeacherMessage.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/SendMessageDTO.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/TeacherMessageService.java
+git commit -m "feat(p1): Wave5.1 — TeacherMessage扩展双向+已读"
+```
+
+### Task 5.2: FollowUp Service + Controller
+
+- [ ] **Step 1: 创建 FollowUpServiceInterface.java**
+
+```java
+package com.etotem.cfc.service.api;
+
+import com.etotem.cfc.entity.FollowUp;
+import java.util.List;
+
+public interface FollowUpServiceInterface {
+    FollowUp create(FollowUp followUp);
+    List<FollowUp> getByTeacherId(Long teacherId);
+    List<FollowUp> getByFamilyId(Long familyId);
+    List<FollowUp> getDueToday(Long teacherId);
+    void updateStatus(Long id, String status);
+    void submitFeedback(Long id, String feedback);
+    void submitReview(Long id, String review);
+    void complete(Long id);
+}
+```
+
+- [ ] **Step 2: 创建 FollowUpService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FollowUp;
+import com.etotem.cfc.mapper.FollowUpMapper;
+import com.etotem.cfc.service.api.FollowUpServiceInterface;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class FollowUpService implements FollowUpServiceInterface {
+
+    @Resource
+    private FollowUpMapper followUpMapper;
+
+    @Override
+    public FollowUp create(FollowUp followUp) {
+        followUp.setStatus("pending");
+        followUpMapper.insert(followUp);
+        return followUp;
+    }
+
+    @Override
+    public List<FollowUp> getByTeacherId(Long teacherId) {
+        LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FollowUp::getTeacherId, teacherId);
+        wrapper.orderByDesc(FollowUp::getCreatedAt);
+        return followUpMapper.selectList(wrapper);
+    }
+
+    @Override
+    public List<FollowUp> getByFamilyId(Long familyId) {
+        LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FollowUp::getFamilyId, familyId);
+        wrapper.orderByDesc(FollowUp::getCreatedAt);
+        return followUpMapper.selectList(wrapper);
+    }
+
+    @Override
+    public List<FollowUp> getDueToday(Long teacherId) {
+        // 使用MySQL DATE函数获取当天到期
+        LambdaQueryWrapper<FollowUp> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FollowUp::getTeacherId, teacherId);
+        wrapper.apply("DATE(due_date) = CURDATE()");
+        wrapper.ne(FollowUp::getStatus, "completed");
+        wrapper.ne(FollowUp::getStatus, "ignored");
+        wrapper.orderByDesc(FollowUp::getPriority);
+        return followUpMapper.selectList(wrapper);
+    }
+
+    @Override
+    public void updateStatus(Long id, String status) {
+        FollowUp followUp = followUpMapper.selectById(id);
+        if (followUp != null) {
+            followUp.setStatus(status);
+            followUpMapper.updateById(followUp);
+        }
+    }
+
+    @Override
+    public void submitFeedback(Long id, String feedback) {
+        FollowUp followUp = followUpMapper.selectById(id);
+        if (followUp != null) {
+            followUp.setParentFeedback(feedback);
+            if ("pending".equals(followUp.getStatus())) {
+                followUp.setStatus("in_progress");
+            }
+            followUpMapper.updateById(followUp);
+        }
+    }
+
+    @Override
+    public void submitReview(Long id, String review) {
+        FollowUp followUp = followUpMapper.selectById(id);
+        if (followUp != null) {
+            followUp.setTeacherReview(review);
+            followUpMapper.updateById(followUp);
+        }
+    }
+
+    @Override
+    public void complete(Long id) {
+        FollowUp followUp = followUpMapper.selectById(id);
+        if (followUp != null) {
+            followUp.setStatus("completed");
+            followUp.setCompletedAt(new Date());
+            followUpMapper.updateById(followUp);
+        }
+    }
+}
+```
+
+- [ ] **Step 3: 创建 FollowUpController.java**
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FollowUp;
+import com.etotem.cfc.service.FollowUpService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/follow-up")
+public class FollowUpController {
+
+    @Resource
+    private FollowUpService followUpService;
+
+    @PostMapping("/create")
+    public Result<FollowUp> create(@RequestBody FollowUp followUp,
+                                   @RequestAttribute("userId") Long userId) {
+        followUp.setTeacherId(userId);
+        return Result.success(followUpService.create(followUp));
+    }
+
+    @PostMapping("/list")
+    public Result<List<FollowUp>> list(@RequestAttribute("userId") Long userId,
+                                       @RequestParam(required = false) String status) {
+        List<FollowUp> list = followUpService.getByTeacherId(userId);
+        if (status != null && !status.isEmpty()) {
+            list.removeIf(f -> !status.equals(f.getStatus()));
+        }
+        return Result.success(list);
+    }
+
+    @PostMapping("/{id}/status")
+    public Result<Void> updateStatus(@PathVariable Long id,
+                                     @RequestParam String status) {
+        followUpService.updateStatus(id, status);
+        return Result.success();
+    }
+
+    @PostMapping("/{id}/feedback")
+    public Result<Void> submitFeedback(@PathVariable Long id,
+                                       @RequestBody Map<String, String> body) {
+        followUpService.submitFeedback(id, body.get("feedback"));
+        return Result.success();
+    }
+
+    @PostMapping("/{id}/review")
+    public Result<Void> submitReview(@PathVariable Long id,
+                                     @RequestBody Map<String, String> body) {
+        followUpService.submitReview(id, body.get("review"));
+        return Result.success();
+    }
+
+    @PostMapping("/{id}/complete")
+    public Result<Void> complete(@PathVariable Long id) {
+        followUpService.complete(id);
+        return Result.success();
+    }
+
+    @PostMapping("/due-today")
+    public Result<List<FollowUp>> dueToday(@RequestAttribute("userId") Long userId) {
+        return Result.success(followUpService.getDueToday(userId));
+    }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/api/FollowUpServiceInterface.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FollowUpService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/FollowUpController.java
+git commit -m "feat(p1): Wave5.2 — FollowUp Service+Controller"
+```
+
+---
+
+## Wave 6: 前端集成(小程序 + Web 管理端)
+
+### Task 6.1: 小程序 — 购物清单页面
+
+- [ ] **Step 1: 在 api.js 中添加购物清单 API**
+
+找到 cfc-frontend/utils/api.js,在文件末尾或其他 API 函数附近添加:
+
+```javascript
+// 购物清单 API
+export const createShoppingListFromReport = (childId, reportId) => {
+  return request('/api/shopping-list/create-from-report', { childId, reportId })
+}
+
+export const getShoppingLists = () => {
+  return request('/api/shopping-list/list', {})
+}
+
+export const getShoppingListItems = (id) => {
+  return request(`/api/shopping-list/${id}/items`, {})
+}
+
+export const markItemPurchased = (itemId) => {
+  return request('/api/shopping-list/item/' + itemId + '/purchase', {})
+}
+
+export const addShoppingItem = (data) => {
+  return request('/api/shopping-list/item/add', data)
+}
+
+// 随访 API
+export const getFollowUpList = (status) => {
+  return request('/api/follow-up/list', { status })
+}
+
+export const submitFollowUpFeedback = (id, feedback) => {
+  return request('/api/follow-up/' + id + '/feedback', { feedback })
+}
+```
+
+- [ ] **Step 2: 创建小程序购物清单页面**
+
+创建 `cfc-frontend/pages/shopping-list/index.vue`(Vue 2 Options API,禁止可选链 `?.` 和 CSS Grid):
+
+```vue
+<template>
+  <view class="shopping-list-page">
+    <view class="page-header">
+      <text class="page-title">购物清单</text>
+      <button class="generate-btn" @click="generateFromReport" v-if="hasReport">
+        基于报告生成
+      </button>
+    </view>
+
+    <view v-if="loading" class="loading-container">
+      <text>加载中...</text>
+    </view>
+
+    <view v-else-if="lists.length === 0" class="empty-state">
+      <text class="empty-icon">📋</text>
+      <text class="empty-text">暂无购物清单</text>
+      <text class="empty-hint">上传菌群报告后可自动生成购物清单</text>
+      <button class="goto-report-btn" @click="gotoReport">
+        去上传报告 →
+      </button>
+    </view>
+
+    <scroll-view v-else scroll-y class="list-scroll">
+      <view v-for="list in lists" :key="list.id" class="list-card">
+        <view class="list-header" @click="toggleExpand(list.id)">
+          <text class="list-title">{{ list.title }}</text>
+          <text class="list-progress">{{ list.purchasedItems }}/{{ list.totalItems }}</text>
+        </view>
+        <view class="progress-bar">
+          <view class="progress-fill" :style="'width: ' + getProgress(list) + '%'"></view>
+        </view>
+        <view v-if="expandedList === list.id" class="list-items">
+          <view v-for="item in getItems(list.id)" :key="item.id" class="item-row">
+            <checkbox :checked="item.purchased" @click="toggleItem(item)" />
+            <view class="item-info">
+              <text class="item-name" :class="{ purchased: item.purchased }">{{ item.foodName }}</text>
+              <text class="item-reason" v-if="item.reason">{{ item.reason }}</text>
+            </view>
+            <text class="item-priority" :class="'priority-' + item.priority">
+              {{ item.score ? item.score + '分' : '' }}
+            </text>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { getShoppingLists, getShoppingListItems, markItemPurchased,
+         createShoppingListFromReport } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      lists: [],
+      itemsMap: {},
+      expandedList: null,
+      loading: true,
+      hasReport: false
+    }
+  },
+  onShow() {
+    this.loadLists()
+  },
+  methods: {
+    loadLists() {
+      this.loading = true
+      getShoppingLists().then(res => {
+        this.lists = res.data || []
+        this.loading = false
+      })
+    },
+    getItems(listId) {
+      return this.itemsMap[listId] || []
+    },
+    toggleExpand(listId) {
+      if (this.expandedList === listId) {
+        this.expandedList = null
+      } else {
+        this.expandedList = listId
+        if (!this.itemsMap[listId]) {
+          getShoppingListItems(listId).then(res => {
+            this.$set(this.itemsMap, listId, res.data || [])
+          })
+        }
+      }
+    },
+    toggleItem(item) {
+      markItemPurchased(item.id).then(() => {
+        item.purchased = !item.purchased
+        this.loadLists()
+      })
+    },
+    generateFromReport() {
+      // 简化:从最近的报告生成
+      uni.showLoading({ title: '生成中...' })
+      createShoppingListFromReport(childId, reportId).then(res => {
+        uni.hideLoading()
+        this.loadLists()
+      })
+    },
+    getProgress(list) {
+      if (list.totalItems === 0) return 0
+      return Math.round(list.purchasedItems / list.totalItems * 100)
+    },
+    gotoReport() {
+      uni.navigateTo({ url: '/pages/health/report-list' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.shopping-list-page {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 20rpx;
+}
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+}
+.generate-btn {
+  background: #07c160;
+  color: white;
+  padding: 10rpx 24rpx;
+  border-radius: 8rpx;
+  font-size: 26rpx;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-top: 200rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+}
+.empty-text {
+  font-size: 32rpx;
+  color: #999;
+  margin-top: 20rpx;
+}
+.empty-hint {
+  font-size: 26rpx;
+  color: #ccc;
+  margin-top: 10rpx;
+}
+.goto-report-btn {
+  margin-top: 40rpx;
+  background: #07c160;
+  color: white;
+  padding: 20rpx 60rpx;
+  border-radius: 12rpx;
+}
+.list-card {
+  background: white;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
+}
+.list-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.list-title {
+  font-size: 30rpx;
+  font-weight: 500;
+}
+.list-progress {
+  font-size: 26rpx;
+  color: #07c160;
+}
+.progress-bar {
+  height: 8rpx;
+  background: #eee;
+  border-radius: 4rpx;
+  margin: 16rpx 0;
+}
+.progress-fill {
+  height: 100%;
+  background: #07c160;
+  border-radius: 4rpx;
+}
+.list-items {
+  border-top: 1rpx solid #f0f0f0;
+  padding-top: 16rpx;
+}
+.item-row {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f8f8f8;
+}
+.item-info {
+  flex: 1;
+  margin-left: 12rpx;
+}
+.item-name {
+  font-size: 28rpx;
+}
+.item-name.purchased {
+  text-decoration: line-through;
+  color: #ccc;
+}
+.item-reason {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.item-priority {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 6rpx;
+}
+.priority-high {
+  background: #ffebee;
+  color: #e53935;
+}
+.priority-medium {
+  background: #fff8e1;
+  color: #f9a825;
+}
+</style>
+```
+
+- [ ] **Step 3: 在 pages.json 中注册页面**
+
+在 `cfc-frontend/pages.json` 中找到合适的分包或主包,添加:
+
+```json
+{
+  "path": "pages/shopping-list/index",
+  "style": {
+    "navigationBarTitleText": "购物清单"
+  }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd cfc-frontend && npx vue-cli-service lint --no-fix 2>/dev/null || true` (无明显错误即可)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git add cfc-frontend/pages/shopping-list/index.vue
+git add cfc-frontend/pages.json
+git commit -m "feat(p1): Wave6.1 — 小程序购物清单页面"
+```
+
+### Task 6.2: 小程序 — 消息+随访页面扩展
+
+- [ ] **Step 1: 扩展消息页面,显示 sender_role 标识**
+
+在 `cfc-frontend/pages/profile/messages.vue` 或类似消息列表中,增加角色标识:
+
+```vue
+<view class="message-item" v-for="msg in messages" :key="msg.id">
+  <view class="msg-header">
+    <text class="sender-role-tag" :class="'role-' + (msg.senderRole || 'parent')">
+      {{ formatRole(msg.senderRole) }}
+    </text>
+    <text class="msg-time">{{ msg.createdAt }}</text>
+  </view>
+  <text class="msg-content">{{ msg.content }}</text>
+  <text class="read-status" v-if="msg.readAt">已读</text>
+  <text class="read-status unread" v-else>未读</text>
+</view>
+```
+
+script 中添加:
+```javascript
+formatRole(role) {
+  var map = { parent: '家长', teacher: '规划师', nutritionist: '营养师' }
+  return map[role] || '系统'
+}
+```
+
+- [ ] **Step 2: 提交**
+
+```bash
+git add cfc-frontend/pages/profile/messages.vue
+git commit -m "feat(p1): Wave6.2 — 消息页面扩展 sender_role 展示"
+```
+
+---
+
+## Verification
+
+### 编译验证
+
+```bash
+# 后端编译
+cd cfc-backend && mvn clean compile -q
+
+# 前端语法检查
+cd cfc-frontend && npx vue-cli-service lint --no-fix 2>/dev/null || true
+```
+
+### 功能验证
+
+| 检查项 | 预期 |
+|--------|------|
+| PDF上传后,食材数据被正确解析存储到 report_food_suitability | report_food_suitability 表有数据 |
+| 同食材第二次不覆盖营养数据 | foods 表 energy_kj 不变 |
+| 通过 API 查询食材的 15+ 微量营养素 | food_nutrients 返回正确数据 |
+| RDI 计算接口返回各年龄段推荐值 | rdi_standards seed 数据存在 |
+| 从报告生成购物清单,score>=50 食材纳入 | shopping_list_items 有数据 |
+| 购物清单勾选已购后进度更新 | purchased 状态变更为 true |
+| 营养师发送消息给家庭 | sender_role=nutritionist |
+| 家长阅读后 read_at 更新 | teacher_messages.read_at 不为空 |
+| 创建随访 → 家长反馈 → 复查完成 | follow_up 状态正确流转 |
+| 小程序购物清单页面正常渲染 | 页面显示清单列表 |
+
+---
+
+## 边界场景
+
+| 场景 | 处理 |
+|------|------|
+| PDF 无食材章节 | `foodSuitability` 为空列表,跳过 |
+| PDF 食材名匹配不到 foods 表 | `food_id=null`,web端人工匹配 |
+| 同一食材同一报告多次出现 | 合并最后一条 |
+| 用户无任何菌群报告 | 仅 user_food_preference 生成空清单 |
+| 缺乏分析生成时已有购物清单 | 追加到 active 清单 |
+| 随访长期未读 | `read_at=null`,due 提醒标出 |