Просмотр исходного кода

feat: add health analysis & recommendation services

Add HealthAnalysisService (gut flora → nutrition label analysis across 6 dimensions) and RecommendationService (cross-table LIKE search for products/activities/articles).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 3 месяцев назад
Родитель
Сommit
6886e3c50d

+ 169 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -0,0 +1,169 @@
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class HealthAnalysisService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    public HealthAnalysisResult analyze(Long reportId) {
+        Map<String, Object> detail = healthReportService.getReportDetail(reportId);
+        HealthReport report = (HealthReport) detail.get("report");
+        if (report == null) {
+            log.warn("报告不存在: reportId={}", reportId);
+            return null;
+        }
+
+        @SuppressWarnings("unchecked")
+        List<HealthGutFlora> gutFloraList = (List<HealthGutFlora>) detail.getOrDefault("gutFlora", Collections.emptyList());
+        @SuppressWarnings("unchecked")
+        List<HealthIndicator> indicators = (List<HealthIndicator>) detail.getOrDefault("indicators", Collections.emptyList());
+
+        List<String> findings = new ArrayList<>();
+        List<String> suggestions = new ArrayList<>();
+        Set<String> nutritionTags = new LinkedHashSet<>();
+
+        // 1. 总分判断
+        if (report.getNutritionScore() != null && report.getNutritionScore() < 60) {
+            findings.add("营养均衡评分偏低(" + report.getNutritionScore() + "分),建议补充多种维生素和矿物质");
+            suggestions.add("增加富含维生素和矿物质的食物摄入");
+            nutritionTags.add("维生素矿物质补充");
+        }
+        if (report.getGutHealthScore() != null && report.getGutHealthScore() < 60) {
+            findings.add("菌群健康评分偏低(" + report.getGutHealthScore() + "分),肠道菌群需要调理");
+            suggestions.add("调节肠道菌群平衡,增加益生元和膳食纤维摄入");
+            nutritionTags.add("肠道菌群调理");
+            nutritionTags.add("益生元补充");
+        }
+
+        // 2. 肠型分析
+        String gutType = report.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. 菌种分析
+        for (HealthGutFlora flora : gutFloraList) {
+            String name = flora.getBacteriaName();
+            String status = flora.getStatus();
+            if (name == null) continue;
+
+            if ("偏低".equals(status) || "low".equalsIgnoreCase(status)) {
+                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)) {
+                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. 指标分析
+        for (HealthIndicator ind : indicators) {
+            String indName = ind.getIndicatorName();
+            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(report.getNutritionScore());
+        result.setGutHealthScore(report.getGutHealthScore());
+        result.setGutType(report.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());
+    }
+}

+ 165 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -0,0 +1,165 @@
+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.*;
+
+/**
+ * 跨表推荐搜索服务
+ * 根据营养需求标签,在 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;
+    }
+}