Ver código fonte

feat(backend): 营养评估与饮食推荐功能

- 新增营养缺乏记录/指标映射实体、Mapper、Service
- 扩展健康报告分析服务(肠道菌群/营养评分)
- 饮食推荐服务完善(季节食材过滤/候选人筛选)
- 数据库初始化增加营养相关表
Sisyphus 2 meses atrás
pai
commit
ab6e6a5284

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2118,6 +2118,86 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建family_invitations表失败: {}", e.getMessage());
         }
 
+        // ==================== P0 营养师: nutrition_indicator_mapping ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_indicator_mapping (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "indicator_name_pattern VARCHAR(200) NOT NULL COMMENT '指标名匹配模式(SQL LIKE语法,%为通配符)'," +
+                "nutrient VARCHAR(50) NOT NULL COMMENT '关联营养素名'," +
+                "severity_weight DECIMAL(3,2) DEFAULT 1.00 COMMENT '权重(0-1)'," +
+                "deficiency_symptoms TEXT COMMENT '缺乏症状描述'," +
+                "excess_symptoms TEXT COMMENT '过量症状描述'," +
+                "food_suggestions TEXT COMMENT '推荐食物(逗号分隔)'," +
+                "sort_order INT DEFAULT 0 COMMENT '排序(大值优先匹配)'," +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "INDEX idx_nutrient (nutrient)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标-营养素映射表'");
+            log.info("已创建 nutrition_indicator_mapping 表");
+        } catch (Exception e) {
+            log.warn("创建 nutrition_indicator_mapping 表失败: {}", e.getMessage());
+        }
+
+        // P0 营养师: nutrition_deficiency_record
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_deficiency_record (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "child_id BIGINT NOT NULL COMMENT '孩子ID'," +
+                "report_id BIGINT NOT NULL COMMENT '关联报告ID'," +
+                "nutrient VARCHAR(50) NOT NULL COMMENT '营养素名'," +
+                "deficiency_type VARCHAR(20) NOT NULL COMMENT 'deficiency/excess/borderline/unclassified'," +
+                "severity DECIMAL(5,2) DEFAULT 0 COMMENT '严重程度(0-1)'," +
+                "indicator_name VARCHAR(100) COMMENT '关联指标名'," +
+                "indicator_value VARCHAR(50) COMMENT '指标检测值'," +
+                "ref_range VARCHAR(100) COMMENT '参考范围'," +
+                "symptoms TEXT COMMENT '症状描述'," +
+                "food_suggestions TEXT COMMENT '推荐食物'," +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "INDEX idx_child (child_id)," +
+                "INDEX idx_report (report_id)," +
+                "INDEX idx_nutrient (nutrient)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养缺乏分析记录'");
+            log.info("已创建 nutrition_deficiency_record 表");
+        } catch (Exception e) {
+            log.warn("创建 nutrition_deficiency_record 表失败: {}", e.getMessage());
+        }
+
+        // P0 营养师: 指标-营养素映射 种子数据
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM nutrition_indicator_mapping", Integer.class);
+            if (count == null || count == 0) {
+                String[][] seedData = {
+                    {"铁蛋白", "铁", "1.00", "乏力、面色苍白、注意力不集中", "铁过量可能损伤肝脏", "动物肝脏,红肉,菠菜,黑木耳", "10"},
+                    {"25-羟基维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "20"},
+                    {"维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育、免疫力下降", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "15"},
+                    {"锌", "锌", "0.80", "食欲减退、味觉异常、免疫力降低", "锌过量影响铜吸收", "牡蛎,牛肉,南瓜子,扁豆", "10"},
+                    {"钙", "钙", "0.80", "骨骼发育迟缓、牙齿问题", "高钙血症、肾结石", "牛奶,豆腐,虾皮,芝麻酱", "10"},
+                    {"维生素B12", "维生素B12", "1.00", "贫血、神经发育迟缓", "一般不易过量", "牛肉,鸡蛋,牛奶,鱼肉", "10"},
+                    {"维生素C", "维生素C", "0.60", "免疫力下降、牙龈出血", "腹泻、肾结石风险", "猕猴桃,橙子,草莓,青椒", "10"},
+                    {"白蛋白", "蛋白质", "0.90", "发育迟缓、肌肉萎缩", "肾脏负担", "鸡蛋,鱼肉,牛奶,豆制品", "10"},
+                    {"总蛋白", "蛋白质", "0.90", "营养不良、免疫力下降", "血液粘稠", "鸡蛋,鱼肉,牛奶,豆制品", "9"},
+                    {"膳食纤维", "膳食纤维", "0.50", "便秘、肠道菌群失衡", "腹胀、排便过多", "全谷物,蔬菜,豆类,水果", "10"},
+                    {"叶酸", "叶酸", "0.70", "贫血、发育迟缓", "一般不易过量", "菠菜,动物肝脏,豆类,柑橘", "10"},
+                    {"维生素A", "维生素A", "0.70", "视力问题、皮肤干燥、免疫力下降", "头痛、肝脏损伤", "胡萝卜,动物肝脏,南瓜,菠菜", "10"},
+                    {"硒", "硒", "0.50", "免疫力下降、甲状腺功能异常", "脱发、指甲脆裂", "巴西坚果,海鲜,蘑菇", "10"},
+                    {"镁", "镁", "0.40", "肌肉痉挛、睡眠问题", "腹泻、低血压", "坚果,全谷物,深绿蔬菜", "10"},
+                    {"益生菌%", "益生菌", "0.60", "肠道菌群失衡、消化问题", "一般不易过量", "酸奶,泡菜,味噌,康普茶", "10"}
+                };
+                for (String[] row : seedData) {
+                    jdbcTemplate.update("INSERT IGNORE INTO nutrition_indicator_mapping " +
+                        "(indicator_name_pattern, nutrient, severity_weight, deficiency_symptoms, excess_symptoms, food_suggestions, sort_order) " +
+                        "VALUES (?, ?, ?, ?, ?, ?, ?)",
+                        row[0], row[1], new java.math.BigDecimal(row[2]), row[3], row[4], row[5], Integer.parseInt(row[6]));
+                }
+                log.info("nutrition_indicator_mapping 种子数据已加载 (15条)");
+            } else {
+                log.info("nutrition_indicator_mapping 种子数据已存在 ({}条), 跳过", count);
+            }
+        } catch (Exception e) {
+            log.warn("nutrition_indicator_mapping 种子数据初始化失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
     }
 

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.HealthAnalysisResult;
 import com.etotem.cfc.dto.ParsedDiseaseRisk;
 import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedIndicator;
@@ -9,7 +10,12 @@ import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.service.ChildNutritionProfileService;
+import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +50,15 @@ public class HealthReportController {
     @Resource
     private PdfParseService pdfParseService;
 
+    @Resource
+    private NutritionDeficiencyService nutritionDeficiencyService;
+
+    @Resource
+    private ChildNutritionProfileService childNutritionProfileService;
+
+    @Resource
+    private HealthAnalysisService healthAnalysisService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -365,6 +380,110 @@ public class HealthReportController {
         }
     }
 
+    @Operation(summary = "获取营养缺乏分析")
+    @PostMapping("/nutrition/deficiency")
+    public Result<List<NutritionDeficiencyRecord>> getDeficiencyAnalysis(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Long reportId = params.get("reportId") != null
+                ? Long.valueOf(params.get("reportId").toString()) : null;
+        List<NutritionDeficiencyRecord> records = nutritionDeficiencyService.analyzeDeficiency(childId, reportId);
+        return Result.success(records);
+    }
+
+    @Operation(summary = "获取孩子营养档案")
+    @PostMapping("/nutrition/profile")
+    public Result<Map<String, Object>> getNutritionProfile(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Map<String, Object> profile = childNutritionProfileService.getProfile(childId);
+        return Result.success(profile);
+    }
+
+    @Operation(summary = "获取营养缺乏摘要")
+    @PostMapping("/nutrition/deficiency-summary")
+    public Result<Map<String, Object>> getDeficiencySummary(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Map<String, Object> summary = childNutritionProfileService.getDeficiencySummary(childId);
+        return Result.success(summary);
+    }
+
+    @Operation(summary = "获取报告趋势数据")
+    @PostMapping("/nutrition/trend")
+    public Result<Map<String, Object>> getReportTrendData(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Map<String, Object> trend = childNutritionProfileService.getReportTrendData(childId);
+        return Result.success(trend);
+    }
+
+    @Operation(summary = "获取健康分析(含营养缺乏)")
+    @PostMapping("/analysis")
+    public Result<Map<String, Object>> getHealthAnalysis(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        HealthAnalysisResult analysisResult = healthAnalysisService.analyzeLatestByUser(childId);
+        List<NutritionDeficiencyRecord> deficiencyRecords = nutritionDeficiencyService.analyzeDeficiency(childId, null);
+
+        Map<String, Object> result = new java.util.HashMap<>();
+        result.put("analysis", analysisResult);
+        result.put("deficiencyRecords", deficiencyRecords);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "管理指标-营养素映射")
+    @PostMapping("/nutrition/mappings")
+    public Result<List<NutritionIndicatorMapping>> listMappings() {
+        List<NutritionIndicatorMapping> mappings = nutritionDeficiencyService.getAllMappings();
+        return Result.success(mappings);
+    }
+
+    @Operation(summary = "创建指标-营养素映射")
+    @PostMapping("/nutrition/mapping/create")
+    public Result<?> createMapping(@RequestBody NutritionIndicatorMapping mapping) {
+        nutritionDeficiencyService.saveMapping(mapping);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "更新指标-营养素映射")
+    @PostMapping("/nutrition/mapping/update")
+    public Result<?> updateMapping(@RequestBody NutritionIndicatorMapping mapping) {
+        nutritionDeficiencyService.saveMapping(mapping);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "删除指标-营养素映射")
+    @PostMapping("/nutrition/mapping/delete")
+    public Result<?> deleteMapping(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        nutritionDeficiencyService.deleteMapping(id);
+        return Result.success(null);
+    }
+
     /**
      * 归档报告
      */

+ 16 - 4
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAnalysisResult.java

@@ -18,12 +18,24 @@ public class HealthAnalysisResult {
     private List<String> findings;
     private List<String> suggestions;
     private List<String> nutritionTags;
+    private List<DeficiencyRecord> deficiencyRecords;
 
     @Data
     public static class FloraSummary {
-        private String category;       // 有益菌/有害菌/中性菌
-        private int normalCount;       // 正常数量
-        private int abnormalCount;     // 异常数量
-        private List<String> keyAbnormal; // 关键异常菌种名称
+        private String category;
+        private int normalCount;
+        private int abnormalCount;
+        private List<String> keyAbnormal;
+    }
+
+    @Data
+    public static class DeficiencyRecord {
+        private String nutrient;
+        private String deficiencyType;
+        private java.math.BigDecimal severity;
+        private String symptoms;
+        private String foodSuggestions;
+        private String indicatorName;
+        private String indicatorValue;
     }
 }

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionDeficiencyRecord.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_deficiency_record")
+public class NutritionDeficiencyRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long childId;
+
+    private Long reportId;
+
+    private String nutrient;
+
+    private String deficiencyType;
+
+    private BigDecimal severity;
+
+    private String indicatorName;
+
+    private String indicatorValue;
+
+    private String refRange;
+
+    private String symptoms;
+
+    private String foodSuggestions;
+
+    private Date createdAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionIndicatorMapping.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_indicator_mapping")
+public class NutritionIndicatorMapping implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String indicatorNamePattern;
+
+    private String nutrient;
+
+    private BigDecimal severityWeight;
+
+    private String deficiencySymptoms;
+
+    private String excessSymptoms;
+
+    private String foodSuggestions;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionDeficiencyRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface NutritionDeficiencyRecordMapper extends BaseMapper<NutritionDeficiencyRecord> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionIndicatorMappingMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface NutritionIndicatorMappingMapper extends BaseMapper<NutritionIndicatorMapping> {
+}

+ 200 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ChildNutritionProfileService.java

@@ -0,0 +1,200 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.HealthCheckin;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.UserNutritionProfile;
+import com.etotem.cfc.mapper.ChildMapper;
+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 ChildNutritionProfileService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private HealthCheckinService healthCheckinService;
+
+    @Resource
+    private NutritionDeficiencyService nutritionDeficiencyService;
+
+    @Resource
+    private UserNutritionProfileService userNutritionProfileService;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    public Map<String, Object> getProfile(Long childId) {
+        Map<String, Object> profile = new HashMap<>();
+
+        Child child = childMapper.selectById(childId);
+        if (child == null) {
+            return profile;
+        }
+        profile.put("childId", childId);
+        profile.put("nickname", child.getNickname());
+        profile.put("age", child.getAge());
+        profile.put("gender", child.getGender());
+
+        HealthReport latestReport = healthReportService.getLatestReport(childId);
+        profile.put("latestReport", latestReport);
+        if (latestReport != null) {
+            profile.put("indicators", healthReportService.getReportIndicators(latestReport.getId()));
+            List<NutritionDeficiencyRecord> records = nutritionDeficiencyService.getDeficiencyRecords(childId, latestReport.getId());
+            if (records == null || records.isEmpty()) {
+                records = nutritionDeficiencyService.analyzeDeficiency(childId, latestReport.getId());
+            }
+            profile.put("deficiencyRecords", records);
+        } else {
+            profile.put("indicators", Collections.emptyList());
+            profile.put("deficiencyRecords", Collections.emptyList());
+        }
+
+        List<HealthReport> reports = healthReportService.getUserReports(childId);
+        profile.put("reportCount", reports != null ? reports.size() : 0);
+        profile.put("reportTimeline", reports);
+
+        Map<String, Object> checkinSummary = buildCheckinSummary(childId);
+        profile.put("checkinSummary", checkinSummary);
+
+        UserNutritionProfile nutritionProfile = userNutritionProfileService.getByUserId(childId);
+        profile.put("nutritionProfile", nutritionProfile);
+
+        return profile;
+    }
+
+    public Map<String, Object> getDeficiencySummary(Long childId) {
+        Map<String, Object> summary = new HashMap<>();
+
+        List<NutritionDeficiencyRecord> allRecords = nutritionDeficiencyService.getDeficiencyRecords(childId, null);
+        summary.put("total", allRecords.size());
+
+        Map<String, List<NutritionDeficiencyRecord>> byType = new HashMap<>();
+        byType.put("deficiency", new ArrayList<>());
+        byType.put("excess", new ArrayList<>());
+        byType.put("borderline", new ArrayList<>());
+
+        for (NutritionDeficiencyRecord r : allRecords) {
+            String type = r.getDeficiencyType();
+            if (byType.containsKey(type)) {
+                byType.get(type).add(r);
+            }
+        }
+
+        summary.put("deficiencyCount", byType.get("deficiency").size());
+        summary.put("excessCount", byType.get("excess").size());
+        summary.put("borderlineCount", byType.get("borderline").size());
+
+        Map<String, List<Map<String, Object>>> nutrientsByType = new HashMap<>();
+        for (Map.Entry<String, List<NutritionDeficiencyRecord>> entry : byType.entrySet()) {
+            List<Map<String, Object>> nutrientList = new ArrayList<>();
+            for (NutritionDeficiencyRecord r : entry.getValue()) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("nutrient", r.getNutrient());
+                item.put("severity", r.getSeverity());
+                item.put("symptoms", r.getSymptoms());
+                item.put("foodSuggestions", r.getFoodSuggestions());
+                item.put("indicatorName", r.getIndicatorName());
+                item.put("indicatorValue", r.getIndicatorValue());
+                item.put("refRange", r.getRefRange());
+                nutrientList.add(item);
+            }
+            nutrientsByType.put(entry.getKey(), nutrientList);
+        }
+        summary.put("nutrientsByType", nutrientsByType);
+
+        Set<String> allFoodSuggestions = new LinkedHashSet<>();
+        for (NutritionDeficiencyRecord r : allRecords) {
+            if (r.getFoodSuggestions() != null) {
+                for (String food : r.getFoodSuggestions().split("[,,]")) {
+                    String trimmed = food.trim();
+                    if (!trimmed.isEmpty()) {
+                        allFoodSuggestions.add(trimmed);
+                    }
+                }
+            }
+        }
+        summary.put("allFoodSuggestions", new ArrayList<>(allFoodSuggestions));
+
+        return summary;
+    }
+
+    public Map<String, Object> getReportTrendData(Long childId) {
+        Map<String, Object> trend = new HashMap<>();
+
+        List<HealthReport> reports = healthReportService.getUserReports(childId);
+        if (reports == null || reports.isEmpty()) {
+            trend.put("dates", Collections.emptyList());
+            trend.put("scores", Collections.emptyList());
+            return trend;
+        }
+
+        List<HealthReport> sorted = new ArrayList<>(reports);
+        sorted.sort((a, b) -> {
+            if (a.getReportDate() == null) return 1;
+            if (b.getReportDate() == null) return -1;
+            return a.getReportDate().compareTo(b.getReportDate());
+        });
+
+        List<String> dates = new ArrayList<>();
+        List<Integer> scores = new ArrayList<>();
+        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+
+        for (HealthReport r : sorted) {
+            dates.add(r.getReportDate() != null ? sdf.format(r.getReportDate()) : "");
+            scores.add(r.getOverallScore() != null ? r.getOverallScore() : 0);
+        }
+
+        trend.put("dates", dates);
+        trend.put("scores", scores);
+
+        Map<String, List<Integer>> subScores = new HashMap<>();
+        List<Integer> nutritionScores = new ArrayList<>();
+        List<Integer> gutScores = new ArrayList<>();
+        for (HealthReport r : sorted) {
+            nutritionScores.add(r.getNutritionScore() != null ? r.getNutritionScore() : 0);
+            gutScores.add(r.getGutHealthScore() != null ? r.getGutHealthScore() : 0);
+        }
+        subScores.put("nutrition", nutritionScores);
+        subScores.put("gutHealth", gutScores);
+        trend.put("subScores", subScores);
+
+        return trend;
+    }
+
+    private Map<String, Object> buildCheckinSummary(Long childId) {
+        Map<String, Object> summary = new HashMap<>();
+        List<HealthCheckin> checkins = healthCheckinService.getCheckins(childId, childId, null);
+        long totalCheckins = checkins != null ? checkins.size() : 0;
+        summary.put("totalCheckins", totalCheckins);
+
+        long recent30Days = 0;
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -30);
+        Date thirtyDaysAgo = cal.getTime();
+        if (checkins != null) {
+            for (HealthCheckin c : checkins) {
+                if (c.getCheckinDate() != null && c.getCheckinDate().after(thirtyDaysAgo)) {
+                    recent30Days++;
+                }
+            }
+        }
+        summary.put("recent30Days", recent30Days);
+
+        if (totalCheckins > 0) {
+            summary.put("streakDays", Math.min((int) totalCheckins, 30));
+        } else {
+            summary.put("streakDays", 0);
+        }
+
+        return summary;
+    }
+}

+ 22 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -4,6 +4,7 @@ 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.entity.NutritionDeficiencyRecord;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -18,6 +19,9 @@ public class HealthAnalysisService {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private NutritionDeficiencyService nutritionDeficiencyService;
+
     public HealthAnalysisResult analyze(Long reportId) {
         Map<String, Object> detail = healthReportService.getReportDetail(reportId);
         HealthReport report = (HealthReport) detail.get("report");
@@ -201,8 +205,24 @@ public class HealthAnalysisService {
         result.setSuggestions(suggestions);
         result.setNutritionTags(new ArrayList<>(nutritionTags));
 
-        log.info("健康分析完成: reportId={}, findings={}, tags={}",
-                reportId, findings.size(), nutritionTags);
+        List<NutritionDeficiencyRecord> deficiencyRecords =
+                nutritionDeficiencyService.analyzeDeficiency(report.getUserId(), reportId);
+        List<HealthAnalysisResult.DeficiencyRecord> dRecords = new ArrayList<>();
+        for (NutritionDeficiencyRecord dr : deficiencyRecords) {
+            HealthAnalysisResult.DeficiencyRecord d = new HealthAnalysisResult.DeficiencyRecord();
+            d.setNutrient(dr.getNutrient());
+            d.setDeficiencyType(dr.getDeficiencyType());
+            d.setSeverity(dr.getSeverity());
+            d.setSymptoms(dr.getSymptoms());
+            d.setFoodSuggestions(dr.getFoodSuggestions());
+            d.setIndicatorName(dr.getIndicatorName());
+            d.setIndicatorValue(dr.getIndicatorValue());
+            dRecords.add(d);
+        }
+        result.setDeficiencyRecords(dRecords);
+
+        log.info("健康分析完成: reportId={}, findings={}, tags={}, deficiencyRecords={}",
+                reportId, findings.size(), nutritionTags, dRecords.size());
         return result;
     }
 

+ 24 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.dto.MealRecommendResult;
 import com.etotem.cfc.dto.RecommendationContext;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -15,9 +16,6 @@ import java.util.stream.Collectors;
 @Service
 public class MealRecommendService {
 
-    @Resource
-    private HealthReportService healthReportService;
-
     @Resource
     private FoodService foodService;
 
@@ -25,7 +23,13 @@ public class MealRecommendService {
     private RecipeService recipeService;
 
     @Resource
-    private SeasonalFoodService seasonalFoodService;
+    private UserNutritionProfileService userNutritionProfileService;
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private NutritionDeficiencyService nutritionDeficiencyService;
 
     @Resource
     private MealLogService mealLogService;
@@ -34,7 +38,7 @@ public class MealRecommendService {
     private UserMapper userMapper;
 
     @Resource
-    private UserNutritionProfileService userNutritionProfileService;
+    private SeasonalFoodService seasonalFoodService;
 
     public RecommendationContext aggregateContext(Long userId) {
         RecommendationContext ctx = new RecommendationContext();
@@ -159,6 +163,21 @@ public class MealRecommendService {
                 })
                 .collect(Collectors.toList()));
 
+        List<NutritionDeficiencyRecord> deficiencyRecords =
+                nutritionDeficiencyService.analyzeDeficiency(userId, null);
+        if (deficiencyRecords != null && !deficiencyRecords.isEmpty()) {
+            List<Map<String, Object>> deficiencyMatrix = new ArrayList<>();
+            for (NutritionDeficiencyRecord dr : deficiencyRecords) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("nutrient", dr.getNutrient());
+                m.put("deficiencyType", dr.getDeficiencyType());
+                m.put("severity", dr.getSeverity());
+                m.put("foodSuggestions", dr.getFoodSuggestions());
+                deficiencyMatrix.add(m);
+            }
+            inputs.put("deficiency_matrix", deficiencyMatrix);
+        }
+
         return inputs;
     }
 }

+ 201 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NutritionDeficiencyService.java

@@ -0,0 +1,201 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.mapper.NutritionDeficiencyRecordMapper;
+import com.etotem.cfc.mapper.NutritionIndicatorMappingMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+
+@Slf4j
+@Service
+public class NutritionDeficiencyService {
+
+    @Resource
+    private NutritionIndicatorMappingMapper mappingMapper;
+
+    @Resource
+    private NutritionDeficiencyRecordMapper recordMapper;
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    public List<NutritionIndicatorMapping> getAllMappings() {
+        return mappingMapper.selectList(
+            new LambdaQueryWrapper<NutritionIndicatorMapping>()
+                .orderByDesc(NutritionIndicatorMapping::getSortOrder)
+        );
+    }
+
+    public void saveMapping(NutritionIndicatorMapping mapping) {
+        if (mapping.getId() == null) {
+            mappingMapper.insert(mapping);
+        } else {
+            mappingMapper.updateById(mapping);
+        }
+    }
+
+    public void deleteMapping(Long id) {
+        mappingMapper.deleteById(id);
+    }
+
+    public List<NutritionDeficiencyRecord> getDeficiencyRecords(Long childId, Long reportId) {
+        LambdaQueryWrapper<NutritionDeficiencyRecord> wrapper = new LambdaQueryWrapper<NutritionDeficiencyRecord>()
+            .eq(NutritionDeficiencyRecord::getChildId, childId);
+        if (reportId != null) {
+            wrapper.eq(NutritionDeficiencyRecord::getReportId, reportId);
+        }
+        wrapper.orderByDesc(NutritionDeficiencyRecord::getSeverity);
+        return recordMapper.selectList(wrapper);
+    }
+
+    @Transactional
+    public List<NutritionDeficiencyRecord> analyzeDeficiency(Long childId, Long reportId) {
+        HealthReport report = healthReportService.getLatestReport(childId);
+        if (report == null) {
+            log.warn("孩子{}无健康报告,无法进行缺乏分析", childId);
+            return Collections.emptyList();
+        }
+
+        if (reportId == null) {
+            reportId = report.getId();
+        }
+
+        List<HealthIndicator> indicators = healthReportService.getReportIndicators(reportId);
+        if (indicators == null || indicators.isEmpty()) {
+            log.warn("报告{}无指标数据,无法进行缺乏分析", reportId);
+            return Collections.emptyList();
+        }
+
+        List<NutritionIndicatorMapping> mappings = getAllMappings();
+
+        deleteRecords(childId, reportId);
+
+        List<NutritionDeficiencyRecord> records = new ArrayList<>();
+        Set<String> matchedNutrients = new HashSet<>();
+
+        for (HealthIndicator indicator : indicators) {
+            String indicatorName = indicator.getIndicatorName();
+            if (indicatorName == null) continue;
+
+            NutritionIndicatorMapping bestMatch = findBestMapping(indicatorName, mappings);
+            if (bestMatch == null) continue;
+
+            if (matchedNutrients.contains(bestMatch.getNutrient())) continue;
+            matchedNutrients.add(bestMatch.getNutrient());
+
+            String deficiencyType = classifyDeficiency(indicator.getStatus());
+
+            if ("unclassified".equals(deficiencyType)) continue;
+
+            BigDecimal severity = calculateSeverity(indicator.getStatus(), bestMatch.getSeverityWeight());
+
+            NutritionDeficiencyRecord record = new NutritionDeficiencyRecord();
+            record.setChildId(childId);
+            record.setReportId(reportId);
+            record.setNutrient(bestMatch.getNutrient());
+            record.setDeficiencyType(deficiencyType);
+            record.setSeverity(severity);
+            record.setIndicatorName(indicatorName);
+            record.setIndicatorValue(indicator.getIndicatorValue());
+            record.setRefRange(indicator.getRefRange());
+            record.setSymptoms("deficiency".equals(deficiencyType) ? bestMatch.getDeficiencySymptoms() : bestMatch.getExcessSymptoms());
+            record.setFoodSuggestions(bestMatch.getFoodSuggestions());
+
+            recordMapper.insert(record);
+            records.add(record);
+        }
+
+        log.info("营养缺乏分析完成: childId={}, reportId={}, 记录数={}", childId, reportId, records.size());
+        return records;
+    }
+
+    NutritionIndicatorMapping findBestMapping(String indicatorName, List<NutritionIndicatorMapping> mappings) {
+        List<NutritionIndicatorMapping> sorted = new ArrayList<>(mappings);
+        sorted.sort((a, b) -> Integer.compare(
+            b.getSortOrder() != null ? b.getSortOrder() : 0,
+            a.getSortOrder() != null ? a.getSortOrder() : 0
+        ));
+
+        for (NutritionIndicatorMapping mapping : sorted) {
+            String pattern = mapping.getIndicatorNamePattern();
+            if (pattern == null) continue;
+
+            if (indicatorName.contains(pattern)) {
+                return mapping;
+            }
+
+            String sqlLikePattern = pattern.replace("%", ".*");
+            if (indicatorName.matches(".*" + sqlLikePattern + ".*")) {
+                return mapping;
+            }
+        }
+        return null;
+    }
+
+    private String classifyDeficiency(String status) {
+        if (status == null) return "unclassified";
+        switch (status) {
+            case "偏低":
+            case "缺乏":
+            case "不足":
+            case "low":
+            case "deficient":
+                return "deficiency";
+            case "偏高":
+            case "过多":
+            case "过量":
+            case "high":
+            case "excess":
+                return "excess";
+            case "临界":
+            case "边缘":
+            case "borderline":
+                return "borderline";
+            default:
+                return "unclassified";
+        }
+    }
+
+    private BigDecimal calculateSeverity(String status, BigDecimal weight) {
+        BigDecimal base;
+        switch (status != null ? status : "") {
+            case "偏低":
+            case "缺乏":
+            case "不足":
+            case "low":
+            case "deficient":
+            case "偏高":
+            case "过多":
+            case "过量":
+            case "high":
+            case "excess":
+                base = BigDecimal.valueOf(0.7);
+                break;
+            case "临界":
+            case "边缘":
+            case "borderline":
+                base = BigDecimal.valueOf(0.4);
+                break;
+            default:
+                base = BigDecimal.valueOf(0.1);
+        }
+        return base.multiply(weight != null ? weight : BigDecimal.ONE).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private void deleteRecords(Long childId, Long reportId) {
+        LambdaQueryWrapper<NutritionDeficiencyRecord> wrapper = new LambdaQueryWrapper<NutritionDeficiencyRecord>()
+            .eq(NutritionDeficiencyRecord::getChildId, childId)
+            .eq(NutritionDeficiencyRecord::getReportId, reportId);
+        recordMapper.delete(wrapper);
+    }
+}