|
|
@@ -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
|
|
|
+```
|