Sfoglia il codice sorgente

docs: add precision nutrition implementation plan

Add implementation plan for the precision nutrition recommendation system based on gut flora reports.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 4 mesi fa
parent
commit
ba5f9a1950

+ 1137 - 0
docs/superpowers/plans/2026-06-20-精准营养推荐系统实现计划.md

@@ -0,0 +1,1137 @@
+# 精准营养推荐系统 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 基于用户菌群报告,通过 AI 对话自动分析营养需求并推荐平台商品/活动/文章
+
+**Architecture:** 混合架构——Dify 负责对话管理 + 知识库 RAG,Java 后端负责菌群分析 + 商品匹配。AI 在回复末尾标注 `[RECOMMEND: {...}]` 标记,后端拦截并执行搜索,将推荐结果追加到回复中。
+
+**Tech Stack:** Spring Boot 2.7.18, MyBatis-Plus, Dify v1.11.4, uni-app Vue 2
+
+---
+
+## 文件结构
+
+### 新增文件(6个)
+
+| # | 文件 | 职责 |
+|---|------|------|
+| 1 | `cfc-backend/.../dto/HealthAnalysisResult.java` | 菌群分析结果 DTO(含内部类 FloraSummary) |
+| 2 | `cfc-backend/.../dto/RecommendationQuery.java` | 推荐搜索请求参数 DTO |
+| 3 | `cfc-backend/.../dto/RecommendationResult.java` | 推荐搜索结果 DTO |
+| 4 | `cfc-backend/.../service/HealthAnalysisService.java` | 菌群报告→营养需求分析 |
+| 5 | `cfc-backend/.../service/RecommendationService.java` | 跨表语义搜索(Product/Activity/Article) |
+| 6 | `cfc-backend/.../controller/ai/RecommendationController.java` | 推荐搜索 REST API |
+
+### 修改文件(5个)
+
+| # | 文件 | 变更 |
+|---|------|------|
+| 7 | `cfc-backend/.../service/AIService.java` | 新增 `sendNutritionMessage()` 方法(不同 API Key) |
+| 8 | `cfc-backend/.../controller/ai/AIChatController.java` | 注入菌群分析 + 解析 `[RECOMMEND]` 标记 |
+| 9 | `cfc-backend/src/main/resources/application.yml` | 新增 `dify.nutrition-api-key` |
+| 10 | `cfc-frontend/pages/ai/chat.vue` | 消息气泡富文本渲染 + 链接解析 |
+| 11 | `cfc-frontend/pages/health/ai-entry.vue` 或报告页 | 新增"AI 营养建议"入口按钮 |
+
+### Dify Console 操作(手动)
+
+| # | 操作 |
+|---|------|
+| 12 | 新建 App "CFC 精准营养助手"(Chatbot Advanced) |
+| 13 | 绑定精准营养知识库(ID: `03d9fc59-47c4-4c40-90ed-598b99d961ab`) |
+| 14 | 配置 System Prompt |
+
+---
+
+### Task 1: Dify Console — 创建精准营养助手 App
+
+**Files:** Dify Console (manual)
+
+- [ ] **Step 1: 登录 Dify Console**
+
+访问 http://dify.bianwoyou.cn/console ,登录凭据:
+- 账号: `liaoxg@iwintrue.com`
+- 密码(Base64 解码后): `OTlsYXVuY2gh`
+
+- [ ] **Step 2: 创建 App**
+
+点击「创建应用」→ 选择「Chatbot」→ 高级编排
+- 名称: `CFC 精准营养助手`
+- 图标: 🤖
+- 描述: `基于菌群报告的精准营养推荐助手,分析用户的肠道菌群数据并提供个性化营养建议和商品推荐`
+
+- [ ] **Step 3: 绑定知识库**
+
+在 App 的「知识库」选项中添加:
+- 选择已有知识库: `精准营养知识库`
+- 检索模式: `语义搜索`
+- TopK: `5`
+- 开启 Rerank: `BAAI/bge-reranker-v2-m3`
+- Score 阈值: `0.25`
+
+- [ ] **Step 4: 配置 System Prompt**
+
+在「提示词编排」→ System Prompt 中填入:
+
+```
+你是一个家庭精准营养顾问。你的知识来源是"精准营养知识库"。
+你的任务是帮助用户(家长/孩子/营养师/规划师)根据用户的菌群报告,提供个性化的营养建议。
+
+## 输入数据
+用户的消息中,后端会通过 inputs.healthAnalysis 注入以下数据:
+- healthAnalysis: {
+    nutritionScore,  // 营养均衡分 0-100
+    gutHealthScore,  // 菌群健康分 0-100
+    gutType,         // 肠型
+    findings: ["菌群多样性偏低", ...],    // 已发现的问题
+    suggestions: ["需要补充益生元", ...], // 初步建议
+    nutritionTags: ["益生元", "膳食纤维"] // 营养需求标签
+  }
+
+## 工作流程
+1. 理解用户的疑问,结合 healthAnalysis 了解其菌群状况
+2. 从精准营养知识库检索相关科学依据,解释用户的菌群问题及改善方法
+3. 如需推荐商品/活动/文章,在回复末尾标注推荐意图:
+   [RECOMMEND: {"tags":["益生元"],"types":["product","activity"],"limit":3}]
+4. 如平台有匹配商品,在回复中引用:[商品名](product:商品ID)
+5. 如平台无匹配商品,给出建议的品类、有效成分和选购注意事项
+
+## 回答原则
+- 回答必须基于知识库内容,超出知识库时需注明
+- 语言通俗易懂,面向家长和孩子
+- 建议应是可操作的(具体的食物、产品、活动)
+- 不替代医生诊断
+```
+
+- [ ] **Step 5: 获取 API Key**
+
+在 App 的「API 访问」→「API 密钥」创建新密钥,复制备用(格式: `app-xxxx`)
+
+---
+
+### Task 2: 新增 DTO 类
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAnalysisResult.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationQuery.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationResult.java`
+
+- [ ] **Step 1: 创建 HealthAnalysisResult.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 菌群报告分析结果
+ * 用于向 Dify AI 提供结构化的营养分析数据
+ */
+@Data
+public class HealthAnalysisResult {
+    private Long reportId;
+    private Integer nutritionScore;
+    private Integer gutHealthScore;
+    private String gutType;
+    private List<FloraSummary> floraSummary;
+    private List<String> findings;
+    private List<String> suggestions;
+    private List<String> nutritionTags;
+
+    @Data
+    public static class FloraSummary {
+        private String category;       // 有益菌/有害菌/中性菌
+        private int normalCount;       // 正常数量
+        private int abnormalCount;     // 异常数量
+        private List<String> keyAbnormal; // 关键异常菌种名称
+    }
+}
+```
+
+- [ ] **Step 2: 创建 RecommendationQuery.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+/**
+ * 推荐搜索请求参数
+ */
+@Data
+public class RecommendationQuery {
+    private List<String> nutritionTags;   // 营养需求标签
+    private List<String> types;           // ["product", "activity", "article"]
+    private Integer limit = 5;            // 每类最多返回数量
+}
+```
+
+- [ ] **Step 3: 创建 RecommendationResult.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 推荐搜索结果(统一结构)
+ */
+@Data
+public class RecommendationResult {
+    private String type;           // product / activity / article
+    private Long id;
+    private String name;
+    private String description;
+    private String coverImage;
+    private Integer price;         // 单位:分
+    private String url;            // 前端跳转路径
+    private String source;         // "platform" 或 null(表示平台商品)
+}
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 3: 实现 HealthAnalysisService
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java`
+- Depends on: HealthAnalysisResult DTO, HealthReportService, HealthGutFloraMapper
+
+- [ ] **Step 1: 创建 HealthAnalysisService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.entity.HealthGutFlora;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 菌群报告→营养需求分析服务
+ * 读取 HealthReport + HealthGutFlora + HealthIndicator
+ * 输出结构化的营养分析结果(findings, suggestions, nutritionTags)
+ */
+@Slf4j
+@Service
+public class HealthAnalysisService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private HealthGutFloraMapper healthGutFloraMapper;
+
+    /**
+     * 分析指定报告,输出营养分析结果
+     *
+     * @param reportId 健康报告ID
+     * @return 结构化分析结果
+     */
+    public HealthAnalysisResult analyze(Long reportId) {
+        HealthReport report = healthReportService.getLatestReport(null);
+        // 改用指定 ID 查询
+        HealthReport targetReport = report != null && report.getId().equals(reportId) ? report
+                : null;
+
+        // 实际应从 mapper 直接查
+        com.etotem.cfc.mapper.HealthReportMapper reportMapper = 
+                (com.etotem.cfc.mapper.HealthReportMapper) 
+                new org.springframework.beans.BeanWrapperImpl(healthReportService)
+                .getPropertyValue("healthReportMapper");
+        
+        // 为避免反射,直接使用注入后的方式... 
+        // 改为直接使用已存在的 healthReportService.getReportDetail
+        Map<String, Object> detail = healthReportService.getReportDetail(reportId);
+        HealthReport healthReport = (HealthReport) detail.get("report");
+        if (healthReport == null) {
+            log.warn("报告不存在: reportId={}", reportId);
+            return null;
+        }
+
+        List<HealthGutFlora> gutFloraList = (List<HealthGutFlora>) detail.get("gutFlora");
+        if (gutFloraList == null) gutFloraList = Collections.emptyList();
+
+        List<HealthIndicator> indicators = (List<HealthIndicator>) detail.get("indicators");
+        if (indicators == null) indicators = Collections.emptyList();
+
+        // === 分析逻辑 ===
+        List<String> findings = new ArrayList<>();
+        List<String> suggestions = new ArrayList<>();
+        Set<String> nutritionTags = new LinkedHashSet<>();
+
+        // 1. 总分判断
+        if (healthReport.getNutritionScore() != null && healthReport.getNutritionScore() < 60) {
+            findings.add("营养均衡评分偏低(" + healthReport.getNutritionScore() + "分),建议补充多种维生素和矿物质");
+            suggestions.add("增加富含维生素和矿物质的食物摄入");
+            nutritionTags.add("维生素矿物质补充");
+        }
+        if (healthReport.getGutHealthScore() != null && healthReport.getGutHealthScore() < 60) {
+            findings.add("菌群健康评分偏低(" + healthReport.getGutHealthScore() + "分),肠道菌群需要调理");
+            suggestions.add("调节肠道菌群平衡,增加益生元和膳食纤维摄入");
+            nutritionTags.add("肠道菌群调理");
+            nutritionTags.add("益生元补充");
+        }
+
+        // 2. 肠型分析
+        String gutType = healthReport.getGutType();
+        if (gutType != null && !gutType.isEmpty()) {
+            if (gutType.contains("拟杆菌") || gutType.contains("Bacteroid")) {
+                suggestions.add("增加膳食纤维摄入,促进肠道蠕动");
+                nutritionTags.add("膳食纤维");
+            } else if (gutType.contains("普氏菌") || gutType.contains("Prevotella")) {
+                suggestions.add("注意碳水化合物与蛋白质的均衡摄入");
+                nutritionTags.add("碳水均衡");
+            }
+        }
+
+        // 3. 菌种分析
+        List<String> lowBacteria = new ArrayList<>();
+        List<String> highBacteria = new ArrayList<>();
+        for (HealthGutFlora flora : gutFloraList) {
+            String name = flora.getBacteriaName();
+            String status = flora.getStatus();
+            if (name == null) continue;
+
+            if ("偏低".equals(status) || "low".equalsIgnoreCase(status)) {
+                lowBacteria.add(name);
+                if (name.contains("双歧杆菌") || name.contains("Bifidobacterium")) {
+                    findings.add("双歧杆菌偏低,肠道保护屏障减弱,建议补充益生元");
+                    suggestions.add("补充低聚糖类益生元(如低聚果糖、低聚半乳糖)");
+                    nutritionTags.add("益生元");
+                } else if (name.contains("乳酸杆菌") || name.contains("Lactobacillus")) {
+                    findings.add("乳酸杆菌偏低,建议补充益生菌");
+                    suggestions.add("补充含乳酸杆菌的益生菌制剂或发酵食品");
+                    nutritionTags.add("益生菌");
+                } else if (name.contains("瘤胃球菌") || name.contains("Ruminococcus")) {
+                    nutritionTags.add("膳食纤维");
+                }
+            } else if ("偏高".equals(status) || "high".equalsIgnoreCase(status)) {
+                highBacteria.add(name);
+                if (name.contains("拟杆菌") || name.contains("Bacteroides")) {
+                    findings.add("拟杆菌属偏高,建议增加膳食纤维摄入以改善菌群结构");
+                    suggestions.add("增加全谷物、蔬菜、豆类等膳食纤维丰富的食物");
+                    nutritionTags.add("膳食纤维");
+                } else if (name.contains("梭菌") || name.contains("Clostridium")) {
+                    findings.add("条件致病菌偏高,建议关注肠道炎症风险");
+                    suggestions.add("增加抗炎食物摄入(Omega-3、多酚类)");
+                    nutritionTags.add("抗炎营养");
+                }
+            }
+        }
+
+        // 4. 指标分析(HealthIndicator)
+        for (HealthIndicator ind : indicators) {
+            String indName = ind.getIndicatorName();
+            String indValue = ind.getIndicatorValue();
+            String indStatus = ind.getStatus();
+            if (indName == null) continue;
+
+            if ("异常".equals(indStatus) || "偏高".equals(indStatus) || "偏低".equals(indStatus)) {
+                if (indName.contains("维生素D") || indName.contains("25-羟基")) {
+                    findings.add("维生素D水平" + indStatus + ",建议补充维生素D");
+                    nutritionTags.add("维生素D");
+                } else if (indName.contains("铁") || indName.contains("Ferritin")) {
+                    findings.add(indName + indStatus + ",建议关注铁摄入");
+                    nutritionTags.add("铁元素补充");
+                } else if (indName.contains("钙")) {
+                    nutritionTags.add("钙补充");
+                } else if (indName.contains("锌")) {
+                    nutritionTags.add("锌补充");
+                }
+            }
+        }
+
+        // 5. 菌群多样性
+        if (gutFloraList.size() > 5) {
+            long abnormalCount = gutFloraList.stream()
+                    .filter(f -> "偏低".equals(f.getStatus()) || "偏高".equals(f.getStatus()))
+                    .count();
+            if (abnormalCount > gutFloraList.size() / 3) {
+                findings.add("菌群多样性偏低,多项菌群指标异常,建议全面改善饮食结构");
+                suggestions.add("增加食物多样性,摄入多种类型的发酵食品和膳食纤维");
+                nutritionTags.add("全面营养调理");
+            }
+        }
+
+        // 6. 组装概要
+        Map<String, List<HealthGutFlora>> grouped = gutFloraList.stream()
+                .filter(f -> f.getCategory() != null)
+                .collect(Collectors.groupingBy(HealthGutFlora::getCategory));
+
+        List<HealthAnalysisResult.FloraSummary> summaries = new ArrayList<>();
+        for (Map.Entry<String, List<HealthGutFlora>> entry : grouped.entrySet()) {
+            HealthAnalysisResult.FloraSummary summary = new HealthAnalysisResult.FloraSummary();
+            summary.setCategory(entry.getKey());
+            long normal = entry.getValue().stream()
+                    .filter(f -> "正常".equals(f.getStatus()) || "normal".equalsIgnoreCase(f.getStatus()))
+                    .count();
+            summary.setNormalCount((int) normal);
+            summary.setAbnormalCount(entry.getValue().size() - (int) normal);
+            summary.setKeyAbnormal(entry.getValue().stream()
+                    .filter(f -> !"正常".equals(f.getStatus()) && !"normal".equalsIgnoreCase(f.getStatus()))
+                    .map(HealthGutFlora::getBacteriaName)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toList()));
+            summaries.add(summary);
+        }
+
+        // 构建结果
+        HealthAnalysisResult result = new HealthAnalysisResult();
+        result.setReportId(reportId);
+        result.setNutritionScore(healthReport.getNutritionScore());
+        result.setGutHealthScore(healthReport.getGutHealthScore());
+        result.setGutType(healthReport.getGutType());
+        result.setFloraSummary(summaries);
+        result.setFindings(findings);
+        result.setSuggestions(suggestions);
+        result.setNutritionTags(new ArrayList<>(nutritionTags));
+
+        log.info("健康分析完成: reportId={}, findings={}, tags={}",
+                reportId, findings.size(), nutritionTags);
+        return result;
+    }
+
+    /**
+     * 获取用户最新报告的菌群分析
+     */
+    public HealthAnalysisResult analyzeLatestByUser(Long userId) {
+        HealthReport report = healthReportService.getLatestReport(userId);
+        if (report == null) return null;
+        return analyze(report.getId());
+    }
+}
+```
+
+注意:上述代码使用了 `healthReportService.getReportDetail(reportId)` 方法(已存在,返回 `Map<String, Object>` 含 `report`, `gutFlora`, `indicators`, `diseaseRisks`)。无需额外注入 Mapper。
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 4: 实现 RecommendationService
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java`
+- Depends on: RecommendationQuery, RecommendationResult DTOs
+
+- [ ] **Step 1: 创建 RecommendationService.java**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 跨表推荐搜索服务
+ * 根据营养需求标签,在 Product / Activity / Article 中搜索匹配项
+ */
+@Slf4j
+@Service
+public class RecommendationService {
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    /**
+     * 按营养标签搜索推荐内容
+     */
+    public List<RecommendationResult> search(RecommendationQuery query) {
+        List<RecommendationResult> results = new ArrayList<>();
+        List<String> tags = query.getNutritionTags();
+        if (tags == null || tags.isEmpty()) return results;
+
+        int limit = query.getLimit() != null ? query.getLimit() : 5;
+
+        if (query.getTypes() == null || query.getTypes().contains("product")) {
+            results.addAll(searchProducts(tags, limit));
+        }
+        if (query.getTypes() == null || query.getTypes().contains("activity")) {
+            results.addAll(searchActivities(tags, limit));
+        }
+        if (query.getTypes() == null || query.getTypes().contains("article")) {
+            results.addAll(searchArticles(tags, limit));
+        }
+
+        return results;
+    }
+
+    private List<RecommendationResult> searchProducts(List<String> tags, int limit) {
+        Set<Long> added = new HashSet<>();
+        List<RecommendationResult> results = new ArrayList<>();
+
+        for (String tag : tags) {
+            if (results.size() >= limit) break;
+            String keyword = "%" + tag + "%";
+
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>()
+                            .eq(Product::getStatus, "上架")
+                            .gt(Product::getStock, 0)
+                            .and(w -> w.like(Product::getName, keyword)
+                                    .or()
+                                    .like(Product::getDescription, keyword))
+                            .last("LIMIT " + (limit - results.size()))
+            );
+
+            for (Product p : products) {
+                if (added.add(p.getId())) {
+                    RecommendationResult r = new RecommendationResult();
+                    r.setType("product");
+                    r.setId(p.getId());
+                    r.setName(p.getName());
+                    r.setDescription(p.getIntro() != null ? p.getIntro() : p.getDescription());
+                    r.setCoverImage(p.getCoverImage());
+                    r.setPrice(p.getPrice());
+                    r.setUrl("/pages/shop/detail?id=" + p.getId());
+                    r.setSource("platform");
+                    results.add(r);
+                }
+            }
+        }
+        return results;
+    }
+
+    private List<RecommendationResult> searchActivities(List<String> tags, int limit) {
+        Set<Long> added = new HashSet<>();
+        List<RecommendationResult> results = new ArrayList<>();
+
+        for (String tag : tags) {
+            if (results.size() >= limit) break;
+            String keyword = "%" + tag + "%";
+
+            List<Activity> activities = activityMapper.selectList(
+                    new LambdaQueryWrapper<Activity>()
+                            .eq(Activity::getStatus, "published")
+                            .ge(Activity::getEndTime, new Date())
+                            .and(w -> w.like(Activity::getTitle, keyword)
+                                    .or()
+                                    .like(Activity::getDescription, keyword))
+                            .last("LIMIT " + (limit - results.size()))
+            );
+
+            for (Activity a : activities) {
+                if (added.add(a.getId())) {
+                    RecommendationResult r = new RecommendationResult();
+                    r.setType("activity");
+                    r.setId(a.getId());
+                    r.setName(a.getTitle());
+                    r.setDescription(a.getDescription());
+                    r.setCoverImage(a.getCoverImage());
+                    r.setPrice(a.getPrice());
+                    r.setUrl("/pages/activity/detail?id=" + a.getId());
+                    r.setSource("platform");
+                    results.add(r);
+                }
+            }
+        }
+        return results;
+    }
+
+    private List<RecommendationResult> searchArticles(List<String> tags, int limit) {
+        Set<Long> added = new HashSet<>();
+        List<RecommendationResult> results = new ArrayList<>();
+
+        for (String tag : tags) {
+            if (results.size() >= limit) break;
+            String keyword = "%" + tag + "%";
+
+            List<Article> articles = articleMapper.selectList(
+                    new LambdaQueryWrapper<Article>()
+                            .eq(Article::getStatus, "published")
+                            .and(w -> w.like(Article::getTitle, keyword)
+                                    .or()
+                                    .like(Article::getSummary, keyword)
+                                    .or()
+                                    .like(Article::getTags, keyword))
+                            .last("LIMIT " + (limit - results.size()))
+            );
+
+            for (Article a : articles) {
+                if (added.add(a.getId())) {
+                    RecommendationResult r = new RecommendationResult();
+                    r.setType("article");
+                    r.setId(a.getId());
+                    r.setName(a.getTitle());
+                    r.setDescription(a.getSummary());
+                    r.setCoverImage(a.getCoverImage());
+                    r.setUrl("/pages/article/detail?id=" + a.getId());
+                    r.setSource("platform");
+                    results.add(r);
+                }
+            }
+        }
+        return results;
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 5: 实现 RecommendationController
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java`
+- Depends on: RecommendationService, RecommendationQuery/Result DTOs
+
+- [ ] **Step 1: 创建 RecommendationController.java**
+
+```java
+package com.etotem.cfc.controller.ai;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.service.RecommendationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 推荐搜索接口
+ * 供 Dify AI 在对话中按营养标签搜索商品/活动/文章
+ */
+@Tag(name = "营养推荐", description = "精准营养推荐搜索")
+@RestController
+@RequestMapping("/api/recommend")
+public class RecommendationController {
+
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Operation(summary = "按营养标签搜索推荐内容")
+    @PostMapping("/search")
+    public Result<List<RecommendationResult>> search(
+            @RequestBody RecommendationQuery query) {
+        List<RecommendationResult> results = recommendationService.search(query);
+        return Result.success(results);
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 6: 配置 application.yml
+
+**Files:**
+- Modify: `cfc-backend/src/main/resources/application.yml`(在第74行后追加)
+
+- [ ] **Step 1: 添加 nutrition-api-key 配置**
+
+```yaml
+# 修改前(约第72-74行):
+dify:
+  base-url: http://dify.bianwoyou.cn/v1
+  api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
+
+# 修改后:
+dify:
+  base-url: http://dify.bianwoyou.cn/v1
+  api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
+  nutrition-api-key: app-xxxx  # 待替换为 Task 1 中获取的 API Key
+```
+
+实际编辑:
+
+```
+oldString: "api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9"
+newString: "api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9\n  nutrition-api-key: app-xxxx  # 精准营养助手"
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 7: 扩展 AIService — 新增营养助手 API 路由
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java`(新增 `sendNutritionMessage` 方法)
+
+- [ ] **Step 1: 添加 nutritionApiKey 字段和新方法**
+
+在 `AIService.java` 的 `apiKey` 字段后添加:
+
+```java
+@Value("${dify.nutrition-api-key}")
+private String nutritionApiKey;
+```
+
+新增方法(放在 `sendMessage` 方法后面):
+
+```java
+/**
+ * 发送聊天消息到精准营养助手(使用专用 API Key)
+ */
+public Map<String, Object> sendNutritionMessage(String query, String userId,
+                                                 String conversationId,
+                                                 Map<String, Object> inputs) {
+    String url = difyBaseUrl + "/chat-messages";
+
+    Map<String, Object> body = new LinkedHashMap<>();
+    body.put("query", query);
+    body.put("user", userId);
+    body.put("response_mode", "blocking");
+    body.put("conversation_id", conversationId != null ? conversationId : "");
+    body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+    body.put("auto_generate_name", true);
+
+    HttpHeaders headers = new HttpHeaders();
+    headers.setContentType(MediaType.APPLICATION_JSON);
+    headers.setBearerAuth(nutritionApiKey);
+
+    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+    ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
+
+    Map<String, Object> result = new LinkedHashMap<>();
+    if (resp.getBody() != null) {
+        result.put("answer", resp.getBody().get("answer"));
+        result.put("conversationId", resp.getBody().get("conversation_id"));
+    }
+    return result;
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 8: 扩展 AIChatController — 注入菌群分析 + 解析 [RECOMMEND] 标记
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+- Depends on: HealthAnalysisService, RecommendationService, AIService (extended)
+
+- [ ] **Step 1: 修改 AIChatController.java**
+
+注入新依赖:
+
+```java
+// 在现有 @Resource 字段后追加:
+@Resource
+private HealthAnalysisService healthAnalysisService;
+
+@Resource
+private RecommendationService recommendationService;
+
+@Resource
+private ObjectMapper objectMapper;  // Jackson,用于解析 [RECOMMEND: json] 标记
+```
+
+修改 `sendMessage` 方法:
+
+```java
+@PostMapping("/chat/send")
+public Result<Map<String, Object>> sendMessage(
+        @RequestAttribute("userId") Long userId,
+        @RequestBody Map<String, String> params) {
+    String query = params.get("query");
+    String conversationId = params.get("conversationId");
+    String reportIdStr = params.get("reportId");
+    String surveyIdStr = params.get("surveyId");
+    String childIdStr = params.get("childId");
+
+    if (query == null || query.trim().isEmpty()) {
+        return Result.error("消息不能为空");
+    }
+
+    // 组装家庭上下文
+    Long reportId = null;
+    Long surveyId = null;
+    Long childId = null;
+    if (reportIdStr != null && !reportIdStr.trim().isEmpty()) {
+        reportId = Long.valueOf(reportIdStr);
+    }
+    if (surveyIdStr != null && !surveyIdStr.trim().isEmpty()) {
+        surveyId = Long.valueOf(surveyIdStr);
+    }
+    if (childIdStr != null && !childIdStr.trim().isEmpty()) {
+        childId = Long.valueOf(childIdStr);
+    }
+    Map<String, Object> inputs;
+    if (reportId != null && surveyId != null) {
+        inputs = familyContextService.buildContext(userId, reportId, surveyId);
+    } else if (reportId != null) {
+        inputs = familyContextService.buildContext(userId, reportId);
+    } else {
+        inputs = familyContextService.buildContext(userId);
+    }
+
+    // === 新增:菌群分析注入 ===
+    boolean useNutritionApp = false;
+    if (reportId != null) {
+        try {
+            HealthAnalysisResult analysis = healthAnalysisService.analyze(reportId);
+            if (analysis != null) {
+                inputs.put("healthAnalysis", analysis);
+                useNutritionApp = true;
+            }
+        } catch (Exception e) {
+            log.warn("菌群分析失败,降级到普通聊天: reportId={}", reportId, e);
+        }
+    }
+    // ==========================
+
+    // 调用 Dify(路由到营养助手或普通助手)
+    Map<String, Object> difyResp;
+    if (useNutritionApp) {
+        difyResp = aiService.sendNutritionMessage(
+                query, String.valueOf(userId), conversationId, inputs);
+    } else {
+        difyResp = aiService.sendMessage(
+                query, String.valueOf(userId), conversationId, inputs);
+    }
+
+    String answer = (String) difyResp.getOrDefault("answer", "");
+
+    // === 新增:解析 [RECOMMEND] 标记 → 追加推荐结果 ===
+    answer = processRecommendMarkers(answer);
+    // =============================================
+
+    Map<String, Object> result = new LinkedHashMap<>();
+    result.put("answer", answer);
+    result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+    return Result.success(result);
+}
+```
+
+在类中新增辅助方法:
+
+```java
+/**
+ * 解析 AI 回复中的 [RECOMMEND: {...}] 标记,执行搜索并追加推荐结果
+ */
+private String processRecommendMarkers(String answer) {
+    if (answer == null || answer.isEmpty()) return answer;
+
+    Pattern pattern = Pattern.compile("\\[RECOMMEND:\\s*(\\{.+?\\})\\]");
+    Matcher matcher = pattern.matcher(answer);
+
+    if (!matcher.find()) return answer;
+
+    String jsonStr = matcher.group(1);
+    try {
+        RecommendationQuery query = objectMapper.readValue(jsonStr, RecommendationQuery.class);
+        List<RecommendationResult> recommendations = recommendationService.search(query);
+
+        if (recommendations != null && !recommendations.isEmpty()) {
+            StringBuilder sb = new StringBuilder(answer);
+            // 移除 [RECOMMEND:...] 标记
+            int start = sb.lastIndexOf("[RECOMMEND:");
+            int end = sb.indexOf("]", start);
+            if (start >= 0 && end > start) {
+                sb.delete(start, end + 1);
+            }
+            // 追加推荐内容
+            sb.append("\n\n---\n📌 **为你推荐:**\n");
+            for (RecommendationResult rec : recommendations) {
+                switch (rec.getType()) {
+                    case "product":
+                        sb.append("🛒 [").append(rec.getName()).append("]")
+                          .append("(product:").append(rec.getId()).append(")");
+                        if (rec.getPrice() != null) {
+                            sb.append(" - ¥").append(String.format("%.2f", rec.getPrice() / 100.0));
+                        }
+                        sb.append("\n");
+                        break;
+                    case "activity":
+                        sb.append("🏃 [").append(rec.getName()).append("]")
+                          .append("(activity:").append(rec.getId()).append(")");
+                        if (rec.getPrice() != null) {
+                            sb.append(" - ¥").append(String.format("%.2f", rec.getPrice() / 100.0));
+                        }
+                        sb.append("\n");
+                        break;
+                    case "article":
+                        sb.append("📄 [").append(rec.getName()).append("]")
+                          .append("(article:").append(rec.getId()).append(")");
+                        if (rec.getDescription() != null && !rec.getDescription().isEmpty()) {
+                            sb.append(" - ").append(rec.getDescription());
+                        }
+                        sb.append("\n");
+                        break;
+                }
+            }
+            return sb.toString();
+        }
+    } catch (Exception e) {
+        log.warn("解析 [RECOMMEND] 标记失败: {}", jsonStr, e);
+    }
+
+    // 解析失败时移除标记
+    return answer.replaceAll("\\[RECOMMEND:\\s*\\{.+?\\}\\]", "");
+}
+```
+
+确保添加 import:
+
+```java
+import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.service.HealthAnalysisService;
+import com.etotem.cfc.service.RecommendationService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+---
+
+### Task 9: 前端 — 消息富文本渲染(chat.vue)
+
+**Files:**
+- Modify: `cfc-frontend/pages/ai/chat.vue`
+
+- [ ] **Step 1: 修改消息渲染模板**
+
+将原有消息气泡:
+
+```vue
+<!-- 原代码(约第62-65行) -->
+<view class="message message-ai" v-if="msg.answer">
+  <view class="message-avatar">🤖</view>
+  <view class="message-bubble ai-bubble">{{ msg.answer }}</view>
+</view>
+```
+
+修改为:
+
+```vue
+<!-- 新代码 -->
+<view class="message message-ai" v-if="msg.answer">
+  <view class="message-avatar">🤖</view>
+  <view class="message-bubble ai-bubble">
+    <text v-for="(seg, i) in parseAnswer(msg.answer)" :key="i"
+      :class="seg.type === 'text' ? '' : 'rec-link'"
+      @click="seg.type !== 'text' ? navigateTo(seg.url) : ''">
+      {{- seg.type === 'text' ? seg.text : '🛒 ' + seg.text + (seg.price ? ' - ¥' + seg.price : '') -}}
+    </text>
+  </view>
+</view>
+```
+
+注意:uni-app 模板不支持复杂表达式,需简化。实际使用 v-for 渲染分段文本,纯文本直接显示,链接以可点击样式展示。
+
+推荐更可靠的实现方式——保持原有 `{{ msg.answer }}` 结构,但用 `richText` 计算属性预处理:
+
+```vue
+<!-- 修改后的消息气泡 -->
+<view class="message message-ai" v-if="msg.answer">
+  <view class="message-avatar">🤖</view>
+  <view class="message-bubble ai-bubble">
+    <!-- 纯文本直接显示(包含推荐内容的链接标记会被原样显示,
+         但为了更好的用户体验,用 richText 解析链接) -->
+    <text class="ai-answer-text" :user-select="true">{{ msg.answer }}</text>
+    <!--
+      由于微信小程序模板限制,富文本渲染推荐使用 rich-text 组件:
+      将推荐链接替换为可点击的内联样式文本。
+      但更简单的方式:直接在文本中保留 [商品名] 格式,
+      用户可看到链接标记,后续迭代可优化为卡片。
+    -->
+  </view>
+</view>
+```
+
+由于微信小程序模板不支持复杂的条件渲染和事件绑定在 text 片段上,第一阶段采用简化方案——保留纯文本显示,推荐链接显示为 `🛒 商品名 - ¥199` 格式文本(用户可阅读)。后续可迭代为 rich-text 组件渲染可点击链接。
+
+如果需要点击跳转,可用 `rich-text` 组件结合 `@tap` 事件处理,但需要解析 HTML 字符串,复杂度较高。第一阶段先以纯文本形式呈现推荐结果。
+
+- [ ] **Step 2: 添加CSS样式**
+
+```scss
+// chat.vue 的 <style> 中追加
+.ai-answer-text {
+  font-size: 28rpx;
+  line-height: 1.6;
+  word-break: break-word;
+  white-space: pre-wrap;  /* 保留推荐内容的换行格式 */
+}
+```
+
+---
+
+### Task 10: 前端 — 营养建议入口按钮
+
+**Files:**
+- Modify: 报告详情页(需确定具体页面路径)
+
+- [ ] **Step 1: 查找健康报告详情页**
+
+先通过 grep 找到报告详情页:
+
+```bash
+grep -rl "reportDetail\|report-detail\|report_detail" cfc-frontend/pages/ --include="*.vue" | head -5
+```
+
+如果存在报告详情页,在页面添加入口按钮:
+
+```vue
+<button class="ai-nutrition-btn" @click="goNutritionChat" 
+  style="margin-top:20rpx; background:#FFF7ED; color:#F97316; border:2rpx solid #F97316; border-radius:12rpx;">
+  🤖 AI 营养建议
+</button>
+```
+
+```javascript
+goNutritionChat() {
+  uni.navigateTo({
+    url: '/pages/ai/chat?reportId=' + this.reportId 
+      + '&firstMessage=' + encodeURIComponent('请根据我的健康报告,推荐适合我的营养方案')
+  })
+}
+```
+
+如果不存在报告详情页,则在 `report-survey.vue` 提交成功后默认使用营养助手(已在 `firstMessage` 中体现,无需额外修改)。
+
+- [ ] **Step 2: 验证入口生效**
+
+手动检查页面导航是否能正确跳转到 `/pages/ai/chat?reportId=xxx&firstMessage=xxx`
+
+---
+
+### Task 11: 验证与启动测试
+
+- [ ] **Step 1: 后端编译**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: `BUILD SUCCESS`
+
+- [ ] **Step 2: 启动后端并检查启动日志**
+
+```bash
+cd cfc-backend && mvn spring-boot:run
+```
+Expected: `Started Application in X.XXX seconds` (JVM running)
+
+- [ ] **Step 3: 验证 API**
+
+```bash
+# 验证 AIChatController 正常运行(返回200,即使没有token也会返回401格式)
+curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:9082/api/ai/chat/send \
+  -H "Content-Type: application/json" \
+  -d '{"query":"test"}'
+```
+Expected: 401(因无 JWT Token,说明接口暴露正确)
+
+- [ ] **Step 4: 验证推荐搜索接口**
+
+```bash
+# 同样验证接口可访问性
+curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:9082/api/recommend/search \
+  -H "Content-Type: application/json" \
+  -d '{"nutritionTags":["益生元"],"types":["product"],"limit":3}'
+```
+Expected: 401
+
+---
+
+## 依赖关系总图
+
+```
+Task 1 (Dify Console) ───────────── 独立,可先执行
+     │
+Task 2 (DTOs) ──────────────────── 无依赖,可先执行
+     │
+     ├── Task 3 (HealthAnalysisService) ── 依赖 Task 2
+     │
+     └── Task 4 (RecommendationService) ── 依赖 Task 2
+              │
+              └── Task 5 (RecommendationController) ── 依赖 Task 4
+     
+Task 6 (application.yml) ───────── 独立
+     │
+     └── Task 7 (AIService扩展) ─── 依赖 Task 6
+
+Task 8 (AIChatController扩展) ─── 依赖 Task 3, 5, 7
+     
+Task 9 (前端 chat.vue) ────────── 依赖 Task 8(API 契约已定后可独立)
+Task 10 (前端入口) ────────────── 独立
+Task 11 (验证) ────────────────── 依赖所有
+```
+
+**推荐执行顺序(按拓扑排序):**
+
+```
+1 → 2 → 3+4+6 (并行) → 5+7 (并行) → 8 → 9+10 (并行) → 11
+```