Jelajahi Sumber

feat(health): add report draft module and food recommendation

Sisyphus 4 bulan lalu
induk
melakukan
4d78808ccc

+ 440 - 138
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.dto.ParsedDiseaseRisk;
 import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedIndicator;
 import com.etotem.cfc.dto.ParsedIndicator;
 import com.etotem.cfc.dto.ParsedFoodSuitability;
 import com.etotem.cfc.dto.ParsedFoodSuitability;
+import com.etotem.cfc.dto.ParsedReportPayload;
 import com.etotem.cfc.dto.ParsedReportResult;
 import com.etotem.cfc.dto.ParsedReportResult;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.Food;
@@ -13,16 +14,18 @@ import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
-import com.etotem.cfc.mapper.FoodMapper;
 import com.etotem.cfc.service.ChildNutritionProfileService;
 import com.etotem.cfc.service.ChildNutritionProfileService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthAnalysisService;
+import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.PdfParseService;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -37,6 +40,7 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.UUID;
 import java.util.UUID;
@@ -66,10 +70,13 @@ public class HealthReportController {
     private HealthAnalysisService healthAnalysisService;
     private HealthAnalysisService healthAnalysisService;
 
 
     @Resource
     @Resource
-    private FoodMapper foodMapper;
+    private FoodService foodService;
 
 
     @Resource
     @Resource
-    private FoodService foodService;
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private ObjectMapper objectMapper;
 
 
     @Resource
     @Resource
     private DimensionScoreService dimensionScoreService;
     private DimensionScoreService dimensionScoreService;
@@ -233,10 +240,9 @@ public class HealthReportController {
     }
     }
 
 
     /**
     /**
-     * 上传并解析肠道菌群报告PDF
-     * 流程: 上传 → 解析 → 保存报告(暂属上传人) → 自动匹配成员 → 前端确认/选择
+     * 上传并解析肠道菌群报告PDF(向后兼容 — 内部自动执行两阶段:解析→确认)
      */
      */
-    @Operation(summary = "上传并解析肠道菌群报告PDF")
+    @Operation(summary = "上传并解析肠道菌群报告PDF(兼容接口,自动确认)")
     @PostMapping("/report/upload")
     @PostMapping("/report/upload")
     public Result<Map<String, Object>> uploadReport(
     public Result<Map<String, Object>> uploadReport(
             @RequestParam("file") MultipartFile file,
             @RequestParam("file") MultipartFile file,
@@ -247,7 +253,6 @@ public class HealthReportController {
             return Result.error("文件不能为空");
             return Result.error("文件不能为空");
         }
         }
 
 
-        // 验证文件类型
         String contentType = file.getContentType();
         String contentType = file.getContentType();
         String originalFilename = file.getOriginalFilename();
         String originalFilename = file.getOriginalFilename();
         if (originalFilename == null || (!originalFilename.toLowerCase().endsWith(".pdf")
         if (originalFilename == null || (!originalFilename.toLowerCase().endsWith(".pdf")
@@ -256,168 +261,235 @@ public class HealthReportController {
         }
         }
 
 
         try {
         try {
-            // 1. 解析PDF
             ParsedReportResult parsed = pdfParseService.parse(file.getInputStream());
             ParsedReportResult parsed = pdfParseService.parse(file.getInputStream());
             if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
             if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
                 return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
                 return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
             }
             }
 
 
-            // 2. 保存文件到本地存储(用上传人ID分目录)
             String fileUrl = saveUploadFile(file, userId);
             String fileUrl = saveUploadFile(file, userId);
 
 
-            // 3. 构建健康报告实体
-            HealthReport report = new HealthReport();
-            report.setUserId(userId);       // 上传人/创建人
-            report.setFamilyId(familyId);
-            report.setReportType("gut_flora");
-            // 报告日期:优先使用PDF提取的日期
-            if (parsed.getReportDate() != null && !parsed.getReportDate().isEmpty()) {
-                try {
-                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
-                    report.setReportDate(sdf.parse(parsed.getReportDate()));
-                } catch (Exception e) {
-                    report.setReportDate(new Date());
-                }
-            } else {
-                report.setReportDate(new Date());
-            }
-            report.setReportNumber(parsed.getReportNumber());
-            report.setPersonName(parsed.getPersonName());
-            report.setOverallScore(parsed.getOverallScore());
-            report.setGutHealthScore(parsed.getGutHealthScore());
-            report.setChronicDiseaseScore(parsed.getChronicDiseaseScore());
-            report.setNutritionScore(parsed.getNutritionScore());
-            report.setBalanceScore(parsed.getBalanceScore());
-            report.setDiversityScore(parsed.getDiversityScore());
-            report.setBeneficialScore(parsed.getBeneficialScore());
-            report.setHarmfulScore(parsed.getHarmfulScore());
-            report.setCoreGenusScore(parsed.getCoreGenusScore());
-            report.setGutAge(parsed.getGutAge());
-            report.setGutType(parsed.getGutType());
-            report.setFileUrl(fileUrl);
-            report.setRemark("募极生物肠道菌群检测报告");
-
-            // 4. 转换指标列表
-            List<HealthIndicator> indicators = new ArrayList<>();
-            if (parsed.getIndicators() != null) {
-                for (int i = 0; i < parsed.getIndicators().size(); i++) {
-                    ParsedIndicator pi = parsed.getIndicators().get(i);
-                    HealthIndicator ind = new HealthIndicator();
-                    ind.setCategory(pi.getCategory());
-                    ind.setIndicatorName(pi.getIndicatorName());
-                    ind.setIndicatorValue(pi.getIndicatorValue());
-                    ind.setStatus(pi.getStatus());
-                    ind.setRefRange(pi.getRefRange());
-                    ind.setSymptoms(pi.getSymptoms());
-                    ind.setSortOrder(i);
-                    indicators.add(ind);
-                }
-            }
+            ParsedReportPayload.Payload payload = convertToPayload(parsed);
+            String payloadJson = objectMapper.writeValueAsString(payload);
 
 
-            // 5. 转换菌群数据(核心菌属 + 有害菌属)
-            List<HealthGutFlora> gutFloraList = new ArrayList<>();
-            if (parsed.getGutFlora() != null) {
-                for (ParsedGutFlora pgf : parsed.getGutFlora()) {
-                    HealthGutFlora gf = new HealthGutFlora();
-                    gf.setBacteriaName(pgf.getBacteriaName());
-                    gf.setBacteriaValue(pgf.getBacteriaValue());
-                    gf.setNormalRange(pgf.getNormalRange());
-                    gf.setPopulationLevel(pgf.getPopulationLevel());
-                    gf.setDetectionRate(pgf.getDetectionRate());
-                    gf.setDescription(pgf.getDescription());
-                    gf.setCategory(pgf.getCategory());
-                    gf.setLevel(pgf.getLevel());
-                    gutFloraList.add(gf);
-                }
+            HealthReportDraft draft = healthReportDraftService.createDraft(
+                    userId, familyId, "gut_flora", fileUrl, originalFilename, payloadJson);
+
+            Long matchedMemberId = null;
+            String matchedMemberName = null;
+            if (familyId != null && parsed.getPersonName() != null) {
+                matchedMemberId = healthReportService.matchFamilyMemberByName(parsed.getPersonName(), familyId);
             }
             }
 
 
-            // 6. 转换益生菌(level=SPECIES)
-            if (parsed.getProbioticSpecies() != null) {
-                for (ParsedGutFlora pgf : parsed.getProbioticSpecies()) {
-                    HealthGutFlora gf = new HealthGutFlora();
-                    gf.setBacteriaName(pgf.getBacteriaName());
-                    gf.setBacteriaValue(pgf.getBacteriaValue());
-                    gf.setPopulationLevel(pgf.getPopulationLevel());
-                    gf.setDetectionRate(pgf.getDetectionRate());
-                    gf.setCategory(pgf.getCategory());
-                    gf.setLevel(pgf.getLevel());
-                    gutFloraList.add(gf);
-                }
+            HealthReport report = buildReportFromPayload(draft, payload, matchedMemberId);
+            List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
+            List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
+            List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
+
+            HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
+
+            if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
+                List<Food> foods = buildFoodsFromPayload(payload);
+                foodService.saveFoodsForReport(created.getId(), foods);
             }
             }
 
 
-            // 7. 转换疾病风险评估
-            List<HealthDiseaseRisk> diseaseRiskList = new ArrayList<>();
-            if (parsed.getDiseaseRisks() != null) {
-                for (ParsedDiseaseRisk pdr : parsed.getDiseaseRisks()) {
-                    HealthDiseaseRisk dr = new HealthDiseaseRisk();
-                    dr.setDiseaseName(pdr.getDiseaseName());
-                    dr.setRiskValue(pdr.getRiskValue());
-                    dr.setRiskLevel(pdr.getRiskLevel());
-                    diseaseRiskList.add(dr);
+            if (matchedMemberId != null) {
+                matchedMemberName = parsed.getPersonName();
+                log.info("报告{}自动匹配到成员{}", created.getId(), matchedMemberId);
+                try {
+                    dimensionScoreService.refreshFromGutReport(matchedMemberId, created.getId());
+                    log.info("已刷新成员{}的7维健康评分", matchedMemberId);
+                } catch (Exception e) {
+                    log.warn("刷新7维健康评分失败: {}", e.getMessage());
                 }
                 }
             }
             }
 
 
-            // 8. 创建完整报告(subjectId = null,尚未绑定成员)
-            HealthReport created = healthReportService.createReport(report, indicators, gutFloraList, diseaseRiskList);
-
-            // 11. 保存食材推荐数据
-            if (parsed.getFoodSuitability() != null && !parsed.getFoodSuitability().isEmpty()) {
-                int sortOrder = 0;
-                for (ParsedFoodSuitability pfs : parsed.getFoodSuitability()) {
-                    Food food = new Food();
-                    food.setReportId(created.getId());
-                    food.setName(pfs.getFoodName());
-                    food.setCategory(pfs.getCategory());
-                    food.setScore(pfs.getScore());
-                    food.setEnergyKj(pfs.getEnergyKj());
-                    food.setProtein(pfs.getProtein());
-                    food.setFat(pfs.getFat());
-                    food.setCarbs(pfs.getCarbs());
-                    food.setStarch(pfs.getStarch());
-                    food.setFiber(pfs.getFiber());
-                    food.setCholesterol(pfs.getCholesterol());
-                    food.setStatus("recommended");
-                    food.setSortOrder(sortOrder++);
-                    food.setCreatedAt(new Date());
-                    food.setUpdatedAt(new Date());
-                    foodMapper.insert(food);
-                }
-                log.info("已保存{}条食材推荐", parsed.getFoodSuitability().size());
+            healthReportDraftService.markConfirmed(draft.getId(), created.getId());
+
+            Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
+            detail.put("matchedMemberId", matchedMemberId);
+            detail.put("matchedMemberName", matchedMemberName);
+            detail.put("extractedName", parsed.getPersonName());
+            detail.put("extractedReportNumber", parsed.getReportNumber());
+            detail.put("extractedReportDate", parsed.getReportDate());
+            detail.put("needBind", matchedMemberId == null);
+            return Result.success(detail);
+
+        } catch (IOException e) {
+            log.error("PDF解析失败", e);
+            return Result.error("PDF解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 两阶段入库 Phase 1: 上传PDF → 解析 → 存草稿 → 返回draftId + 解析载荷 + 成员匹配
+     * 不落库到 health_reports 等正式表。
+     */
+    @Operation(summary = "上传解析报告(预览,不入库)")
+    @PostMapping("/report/parse-preview")
+    public Result<Map<String, Object>> parsePreview(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+
+        String contentType = file.getContentType();
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null || (!originalFilename.toLowerCase().endsWith(".pdf")
+                && (contentType == null || !contentType.equalsIgnoreCase("application/pdf")))) {
+            return Result.error("仅支持PDF文件");
+        }
+
+        try {
+            ParsedReportResult parsed = pdfParseService.parse(file.getInputStream());
+            if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
+                return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
             }
             }
 
 
-            // 10. 尝试自动匹配家庭成员(通过PDF提取的姓名)
+            String fileUrl = saveUploadFile(file, userId);
+
+            ParsedReportPayload.Payload payload = convertToPayload(parsed);
+
+            String payloadJson = objectMapper.writeValueAsString(payload);
+
+            HealthReportDraft draft = healthReportDraftService.createDraft(
+                    userId, familyId, "gut_flora", fileUrl, originalFilename, payloadJson);
+
             Long matchedMemberId = null;
             Long matchedMemberId = null;
-            String matchedMemberName = null;
             if (familyId != null && parsed.getPersonName() != null) {
             if (familyId != null && parsed.getPersonName() != null) {
                 matchedMemberId = healthReportService.matchFamilyMemberByName(parsed.getPersonName(), familyId);
                 matchedMemberId = healthReportService.matchFamilyMemberByName(parsed.getPersonName(), familyId);
-                if (matchedMemberId != null) {
-                    healthReportService.bindReportToMember(created.getId(), matchedMemberId);
-                    matchedMemberName = parsed.getPersonName();
-                    log.info("报告{}自动匹配到成员{}", created.getId(), matchedMemberId);
-                    // 刷新7维评分(基于菌群报告5维映射矩阵)
+            }
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("draftId", draft.getId());
+            result.put("payload", payload);
+            result.put("matchedMemberId", matchedMemberId);
+            result.put("extractedName", parsed.getPersonName());
+            result.put("needBind", matchedMemberId == null && familyId != null);
+            return Result.success(result);
+
+        } catch (IOException e) {
+            log.error("PDF解析失败", e);
+            return Result.error("PDF解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 两阶段入库 Phase 2: 确认草稿 → 写入正式表 → 刷新7维评分
+     * 前端可回传修改后的 payload(用户在线编辑指标后整体提交)。
+     */
+    @Operation(summary = "确认报告草稿(正式入库)")
+    @PostMapping("/report/confirm")
+    public Result<Map<String, Object>> confirmDraft(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在、已确认或已过期");
+        }
+        if (!draft.getUserId().equals(userId)) {
+            return Result.error("无权操作此草稿");
+        }
+
+        try {
+            ParsedReportPayload.Payload payload;
+            Object payloadObj = params.get("payload");
+            if (payloadObj != null) {
+                String payloadJson = objectMapper.writeValueAsString(payloadObj);
+                payload = objectMapper.readValue(payloadJson, ParsedReportPayload.Payload.class);
+            } else {
+                payload = objectMapper.readValue(draft.getPayloadJson(), ParsedReportPayload.Payload.class);
+            }
+
+            Long subjectId = params.get("subjectId") != null
+                    ? Long.valueOf(params.get("subjectId").toString()) : null;
+
+            HealthReport report = buildReportFromPayload(draft, payload, subjectId);
+
+            List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
+
+            List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
+
+            List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
+
+            HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
+
+            if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
+                List<Food> foods = buildFoodsFromPayload(payload);
+                foodService.saveFoodsForReport(created.getId(), foods);
+            }
+
+            if (subjectId != null) {
+                healthReportService.bindReportToMember(created.getId(), subjectId);
+                try {
+                    dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
+                    log.info("已刷新成员{}的7维健康评分", subjectId);
+                } catch (Exception e) {
+                    log.warn("刷新7维健康评分失败: {}", e.getMessage());
+                }
+            } else if (draft.getFamilyId() != null && payload.getSummary() != null
+                    && payload.getSummary().getPersonName() != null) {
+                Long matchedId = healthReportService.matchFamilyMemberByName(
+                        payload.getSummary().getPersonName(), draft.getFamilyId());
+                if (matchedId != null) {
+                    healthReportService.bindReportToMember(created.getId(), matchedId);
                     try {
                     try {
-                        dimensionScoreService.refreshFromGutReport(matchedMemberId, created.getId());
-                        log.info("已刷新成员{}的7维健康评分", matchedMemberId);
+                        dimensionScoreService.refreshFromGutReport(matchedId, created.getId());
                     } catch (Exception e) {
                     } catch (Exception e) {
                         log.warn("刷新7维健康评分失败: {}", e.getMessage());
                         log.warn("刷新7维健康评分失败: {}", e.getMessage());
                     }
                     }
+                    subjectId = matchedId;
                 }
                 }
             }
             }
 
 
-            // 11. 返回详情
+            healthReportDraftService.markConfirmed(draftId, created.getId());
+
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
-            detail.put("matchedMemberId", matchedMemberId);
-            detail.put("matchedMemberName", matchedMemberName);
-            detail.put("extractedName", parsed.getPersonName());
-            detail.put("extractedReportNumber", parsed.getReportNumber());
-            detail.put("extractedReportDate", parsed.getReportDate());
-            detail.put("needBind", matchedMemberId == null);
+            if (subjectId != null) {
+                detail.put("subjectId", subjectId);
+            }
             return Result.success(detail);
             return Result.success(detail);
 
 
-        } catch (IOException e) {
-            log.error("PDF解析失败", e);
-            return Result.error("PDF解析失败: " + e.getMessage());
+        } catch (Exception e) {
+            log.error("确认草稿失败", e);
+            return Result.error("确认草稿失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 放弃草稿
+     */
+    @Operation(summary = "放弃报告草稿")
+    @PostMapping("/report/discard")
+    public Result<String> discardDraft(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在或已处理");
         }
         }
+        if (!draft.getUserId().equals(userId)) {
+            return Result.error("无权操作此草稿");
+        }
+
+        healthReportDraftService.markDiscarded(draftId);
+        return Result.success("草稿已放弃");
     }
     }
 
 
     /**
     /**
@@ -640,6 +712,236 @@ public class HealthReportController {
         return Result.success("报告已归档");
         return Result.success("报告已归档");
     }
     }
 
 
+    private ParsedReportPayload.Payload convertToPayload(ParsedReportResult parsed) {
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+
+        ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+        summary.setOverallScore(parsed.getOverallScore());
+        summary.setGutHealthScore(parsed.getGutHealthScore());
+        summary.setChronicDiseaseScore(parsed.getChronicDiseaseScore());
+        summary.setNutritionScore(parsed.getNutritionScore());
+        summary.setBalanceScore(parsed.getBalanceScore());
+        summary.setDiversityScore(parsed.getDiversityScore());
+        summary.setBeneficialScore(parsed.getBeneficialScore());
+        summary.setHarmfulScore(parsed.getHarmfulScore());
+        summary.setCoreGenusScore(parsed.getCoreGenusScore());
+        summary.setGutAge(parsed.getGutAge());
+        summary.setGutType(parsed.getGutType());
+        summary.setReportNumber(parsed.getReportNumber());
+        summary.setReportDate(parsed.getReportDate());
+        summary.setPersonName(parsed.getPersonName());
+        payload.setSummary(summary);
+
+        if (parsed.getIndicators() != null) {
+            List<ParsedReportPayload.Indicator> indicators = new ArrayList<>();
+            for (ParsedIndicator pi : parsed.getIndicators()) {
+                ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
+                ind.setCategory(pi.getCategory());
+                ind.setIndicatorName(pi.getIndicatorName());
+                ind.setIndicatorValue(pi.getIndicatorValue());
+                ind.setRefRange(pi.getRefRange());
+                ind.setStatus(pi.getStatus());
+                ind.setSymptoms(pi.getSymptoms());
+                indicators.add(ind);
+            }
+            payload.setIndicators(indicators);
+        }
+
+        if (parsed.getGutFlora() != null) {
+            List<ParsedReportPayload.Flora> gutFlora = new ArrayList<>();
+            for (ParsedGutFlora pgf : parsed.getGutFlora()) {
+                gutFlora.add(convertFlora(pgf));
+            }
+            payload.setGutFlora(gutFlora);
+        }
+
+        if (parsed.getProbioticSpecies() != null) {
+            List<ParsedReportPayload.Flora> probiotics = new ArrayList<>();
+            for (ParsedGutFlora pgf : parsed.getProbioticSpecies()) {
+                probiotics.add(convertFlora(pgf));
+            }
+            payload.setProbioticSpecies(probiotics);
+        }
+
+        if (parsed.getFoodSuitability() != null) {
+            List<ParsedReportPayload.FoodItem> foods = new ArrayList<>();
+            for (ParsedFoodSuitability pfs : parsed.getFoodSuitability()) {
+                ParsedReportPayload.FoodItem fi = new ParsedReportPayload.FoodItem();
+                fi.setName(pfs.getFoodName());
+                fi.setCategory(pfs.getCategory());
+                fi.setScore(pfs.getScore());
+                fi.setEnergyKj(pfs.getEnergyKj());
+                fi.setProtein(pfs.getProtein());
+                fi.setFat(pfs.getFat());
+                fi.setCarbs(pfs.getCarbs());
+                fi.setStarch(pfs.getStarch());
+                fi.setFiber(pfs.getFiber());
+                fi.setCholesterol(pfs.getCholesterol());
+                foods.add(fi);
+            }
+            payload.setFoods(foods);
+        }
+
+        if (parsed.getDiseaseRisks() != null) {
+            List<ParsedReportPayload.DiseaseRisk> risks = new ArrayList<>();
+            for (ParsedDiseaseRisk pdr : parsed.getDiseaseRisks()) {
+                ParsedReportPayload.DiseaseRisk dr = new ParsedReportPayload.DiseaseRisk();
+                dr.setDiseaseName(pdr.getDiseaseName());
+                dr.setRiskValue(pdr.getRiskValue());
+                dr.setRiskLevel(pdr.getRiskLevel());
+                risks.add(dr);
+            }
+            payload.setDiseaseRisks(risks);
+        }
+
+        return payload;
+    }
+
+    private ParsedReportPayload.Flora convertFlora(ParsedGutFlora pgf) {
+        ParsedReportPayload.Flora f = new ParsedReportPayload.Flora();
+        f.setBacteriaName(pgf.getBacteriaName());
+        f.setBacteriaValue(pgf.getBacteriaValue());
+        f.setNormalRange(pgf.getNormalRange());
+        f.setPopulationLevel(pgf.getPopulationLevel());
+        f.setDetectionRate(pgf.getDetectionRate());
+        f.setDescription(pgf.getDescription());
+        f.setCategory(pgf.getCategory());
+        f.setLevel(pgf.getLevel());
+        return f;
+    }
+
+    private HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
+        HealthReport report = new HealthReport();
+        report.setUserId(draft.getUserId());
+        report.setFamilyId(draft.getFamilyId());
+        report.setSubjectId(subjectId);
+        report.setReportType(draft.getReportType());
+        report.setFileUrl(draft.getFileUrl());
+
+        ParsedReportPayload.Summary s = payload.getSummary();
+        if (s != null) {
+            report.setOverallScore(s.getOverallScore());
+            report.setGutHealthScore(s.getGutHealthScore());
+            report.setChronicDiseaseScore(s.getChronicDiseaseScore());
+            report.setNutritionScore(s.getNutritionScore());
+            report.setBalanceScore(s.getBalanceScore());
+            report.setDiversityScore(s.getDiversityScore());
+            report.setBeneficialScore(s.getBeneficialScore());
+            report.setHarmfulScore(s.getHarmfulScore());
+            report.setCoreGenusScore(s.getCoreGenusScore());
+            report.setGutAge(s.getGutAge());
+            report.setGutType(s.getGutType());
+            report.setReportNumber(s.getReportNumber());
+            report.setPersonName(s.getPersonName());
+
+            if (s.getReportDate() != null && !s.getReportDate().isEmpty()) {
+                try {
+                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                    report.setReportDate(sdf.parse(s.getReportDate()));
+                } catch (Exception e) {
+                    report.setReportDate(new Date());
+                }
+            } else {
+                report.setReportDate(new Date());
+            }
+        } else {
+            report.setReportDate(new Date());
+        }
+
+        report.setRemark("募极生物肠道菌群检测报告");
+        return report;
+    }
+
+    private List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
+        List<ParsedReportPayload.Indicator> list = payload.getIndicators();
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        List<HealthIndicator> indicators = new ArrayList<>();
+        for (int i = 0; i < list.size(); i++) {
+            ParsedReportPayload.Indicator pi = list.get(i);
+            HealthIndicator ind = new HealthIndicator();
+            ind.setCategory(pi.getCategory());
+            ind.setIndicatorName(pi.getIndicatorName());
+            ind.setIndicatorValue(pi.getIndicatorValue());
+            ind.setUnit(pi.getUnit());
+            ind.setRefRange(pi.getRefRange());
+            ind.setStatus(pi.getStatus());
+            ind.setSymptoms(pi.getSymptoms());
+            ind.setSortOrder(i);
+            indicators.add(ind);
+        }
+        return indicators;
+    }
+
+    private List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
+        List<HealthGutFlora> result = new ArrayList<>();
+
+        if (payload.getGutFlora() != null) {
+            for (ParsedReportPayload.Flora pf : payload.getGutFlora()) {
+                result.add(convertToGutFlora(pf));
+            }
+        }
+
+        if (payload.getProbioticSpecies() != null) {
+            for (ParsedReportPayload.Flora pf : payload.getProbioticSpecies()) {
+                result.add(convertToGutFlora(pf));
+            }
+        }
+
+        return result;
+    }
+
+    private HealthGutFlora convertToGutFlora(ParsedReportPayload.Flora pf) {
+        HealthGutFlora gf = new HealthGutFlora();
+        gf.setBacteriaName(pf.getBacteriaName());
+        gf.setBacteriaValue(pf.getBacteriaValue());
+        gf.setNormalRange(pf.getNormalRange());
+        gf.setPopulationLevel(pf.getPopulationLevel());
+        gf.setDetectionRate(pf.getDetectionRate());
+        gf.setDescription(pf.getDescription());
+        gf.setCategory(pf.getCategory());
+        gf.setLevel(pf.getLevel());
+        return gf;
+    }
+
+    private List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
+        List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        List<HealthDiseaseRisk> risks = new ArrayList<>();
+        for (ParsedReportPayload.DiseaseRisk pr : list) {
+            HealthDiseaseRisk dr = new HealthDiseaseRisk();
+            dr.setDiseaseName(pr.getDiseaseName());
+            dr.setRiskValue(pr.getRiskValue());
+            dr.setRiskLevel(pr.getRiskLevel());
+            risks.add(dr);
+        }
+        return risks;
+    }
+
+    private List<Food> buildFoodsFromPayload(ParsedReportPayload.Payload payload) {
+        List<ParsedReportPayload.FoodItem> list = payload.getFoods();
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        List<Food> foods = new ArrayList<>();
+        for (ParsedReportPayload.FoodItem pf : list) {
+            Food food = new Food();
+            food.setName(pf.getName());
+            food.setCategory(pf.getCategory());
+            food.setScore(pf.getScore());
+            food.setEnergyKj(pf.getEnergyKj());
+            food.setProtein(pf.getProtein());
+            food.setFat(pf.getFat());
+            food.setCarbs(pf.getCarbs());
+            food.setStarch(pf.getStarch());
+            food.setFiber(pf.getFiber());
+            food.setCholesterol(pf.getCholesterol());
+            food.setStatus("recommended");
+            foods.add(food);
+        }
+        return foods;
+    }
+
     /**
     /**
      * 保存上传文件到本地存储
      * 保存上传文件到本地存储
      */
      */

+ 179 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java

@@ -0,0 +1,179 @@
+package com.etotem.cfc.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 两阶段入库共享载荷 — parse-preview 与 confirm 共用的非实体字段结构。
+ * 在 draft 表中以 JSON 形式存储,前端 confirm 时可整体回传或改写。
+ */
+public class ParsedReportPayload {
+
+    public static class Summary {
+        private Integer overallScore;
+        private Integer gutHealthScore;
+        private Integer chronicDiseaseScore;
+        private Integer nutritionScore;
+        private Integer balanceScore;
+        private Integer diversityScore;
+        private Integer beneficialScore;
+        private Integer harmfulScore;
+        private Integer coreGenusScore;
+        private String gutAge;
+        private String gutType;
+        private String reportNumber;
+        private String reportDate;
+        private String personName;
+
+        public Integer getOverallScore() { return overallScore; }
+        public void setOverallScore(Integer overallScore) { this.overallScore = overallScore; }
+        public Integer getGutHealthScore() { return gutHealthScore; }
+        public void setGutHealthScore(Integer gutHealthScore) { this.gutHealthScore = gutHealthScore; }
+        public Integer getChronicDiseaseScore() { return chronicDiseaseScore; }
+        public void setChronicDiseaseScore(Integer chronicDiseaseScore) { this.chronicDiseaseScore = chronicDiseaseScore; }
+        public Integer getNutritionScore() { return nutritionScore; }
+        public void setNutritionScore(Integer nutritionScore) { this.nutritionScore = nutritionScore; }
+        public Integer getBalanceScore() { return balanceScore; }
+        public void setBalanceScore(Integer balanceScore) { this.balanceScore = balanceScore; }
+        public Integer getDiversityScore() { return diversityScore; }
+        public void setDiversityScore(Integer diversityScore) { this.diversityScore = diversityScore; }
+        public Integer getBeneficialScore() { return beneficialScore; }
+        public void setBeneficialScore(Integer beneficialScore) { this.beneficialScore = beneficialScore; }
+        public Integer getHarmfulScore() { return harmfulScore; }
+        public void setHarmfulScore(Integer harmfulScore) { this.harmfulScore = harmfulScore; }
+        public Integer getCoreGenusScore() { return coreGenusScore; }
+        public void setCoreGenusScore(Integer coreGenusScore) { this.coreGenusScore = coreGenusScore; }
+        public String getGutAge() { return gutAge; }
+        public void setGutAge(String gutAge) { this.gutAge = gutAge; }
+        public String getGutType() { return gutType; }
+        public void setGutType(String gutType) { this.gutType = gutType; }
+        public String getReportNumber() { return reportNumber; }
+        public void setReportNumber(String reportNumber) { this.reportNumber = reportNumber; }
+        public String getReportDate() { return reportDate; }
+        public void setReportDate(String reportDate) { this.reportDate = reportDate; }
+        public String getPersonName() { return personName; }
+        public void setPersonName(String personName) { this.personName = personName; }
+    }
+
+    public static class Indicator {
+        private String category;
+        private String indicatorName;
+        private String indicatorValue;
+        private String unit;
+        private String refRange;
+        private String status;
+        private String symptoms;
+
+        public String getCategory() { return category; }
+        public void setCategory(String category) { this.category = category; }
+        public String getIndicatorName() { return indicatorName; }
+        public void setIndicatorName(String indicatorName) { this.indicatorName = indicatorName; }
+        public String getIndicatorValue() { return indicatorValue; }
+        public void setIndicatorValue(String indicatorValue) { this.indicatorValue = indicatorValue; }
+        public String getUnit() { return unit; }
+        public void setUnit(String unit) { this.unit = unit; }
+        public String getRefRange() { return refRange; }
+        public void setRefRange(String refRange) { this.refRange = refRange; }
+        public String getStatus() { return status; }
+        public void setStatus(String status) { this.status = status; }
+        public String getSymptoms() { return symptoms; }
+        public void setSymptoms(String symptoms) { this.symptoms = symptoms; }
+    }
+
+    public static class Flora {
+        private String bacteriaName;
+        private String bacteriaValue;
+        private String normalRange;
+        private String populationLevel;
+        private String detectionRate;
+        private String description;
+        private String category;
+        private String level;
+
+        public String getBacteriaName() { return bacteriaName; }
+        public void setBacteriaName(String bacteriaName) { this.bacteriaName = bacteriaName; }
+        public String getBacteriaValue() { return bacteriaValue; }
+        public void setBacteriaValue(String bacteriaValue) { this.bacteriaValue = bacteriaValue; }
+        public String getNormalRange() { return normalRange; }
+        public void setNormalRange(String normalRange) { this.normalRange = normalRange; }
+        public String getPopulationLevel() { return populationLevel; }
+        public void setPopulationLevel(String populationLevel) { this.populationLevel = populationLevel; }
+        public String getDetectionRate() { return detectionRate; }
+        public void setDetectionRate(String detectionRate) { this.detectionRate = detectionRate; }
+        public String getDescription() { return description; }
+        public void setDescription(String description) { this.description = description; }
+        public String getCategory() { return category; }
+        public void setCategory(String category) { this.category = category; }
+        public String getLevel() { return level; }
+        public void setLevel(String level) { this.level = level; }
+    }
+
+    public static class FoodItem {
+        private String name;
+        private String category;
+        private Integer score;
+        private BigDecimal energyKj;
+        private BigDecimal protein;
+        private BigDecimal fat;
+        private BigDecimal carbs;
+        private BigDecimal starch;
+        private BigDecimal fiber;
+        private BigDecimal cholesterol;
+
+        public String getName() { return name; }
+        public void setName(String name) { this.name = name; }
+        public String getCategory() { return category; }
+        public void setCategory(String category) { this.category = category; }
+        public Integer getScore() { return score; }
+        public void setScore(Integer score) { this.score = score; }
+        public BigDecimal getEnergyKj() { return energyKj; }
+        public void setEnergyKj(BigDecimal energyKj) { this.energyKj = energyKj; }
+        public BigDecimal getProtein() { return protein; }
+        public void setProtein(BigDecimal protein) { this.protein = protein; }
+        public BigDecimal getFat() { return fat; }
+        public void setFat(BigDecimal fat) { this.fat = fat; }
+        public BigDecimal getCarbs() { return carbs; }
+        public void setCarbs(BigDecimal carbs) { this.carbs = carbs; }
+        public BigDecimal getStarch() { return starch; }
+        public void setStarch(BigDecimal starch) { this.starch = starch; }
+        public BigDecimal getFiber() { return fiber; }
+        public void setFiber(BigDecimal fiber) { this.fiber = fiber; }
+        public BigDecimal getCholesterol() { return cholesterol; }
+        public void setCholesterol(BigDecimal cholesterol) { this.cholesterol = cholesterol; }
+    }
+
+    public static class DiseaseRisk {
+        private String diseaseName;
+        private String riskValue;
+        private String riskLevel;
+
+        public String getDiseaseName() { return diseaseName; }
+        public void setDiseaseName(String diseaseName) { this.diseaseName = diseaseName; }
+        public String getRiskValue() { return riskValue; }
+        public void setRiskValue(String riskValue) { this.riskValue = riskValue; }
+        public String getRiskLevel() { return riskLevel; }
+        public void setRiskLevel(String riskLevel) { this.riskLevel = riskLevel; }
+    }
+
+    public static class Payload {
+        private Summary summary;
+        private List<Indicator> indicators;
+        private List<Flora> gutFlora;
+        private List<Flora> probioticSpecies;
+        private List<FoodItem> foods;
+        private List<DiseaseRisk> diseaseRisks;
+
+        public Summary getSummary() { return summary; }
+        public void setSummary(Summary summary) { this.summary = summary; }
+        public List<Indicator> getIndicators() { return indicators; }
+        public void setIndicators(List<Indicator> indicators) { this.indicators = indicators; }
+        public List<Flora> getGutFlora() { return gutFlora; }
+        public void setGutFlora(List<Flora> gutFlora) { this.gutFlora = gutFlora; }
+        public List<Flora> getProbioticSpecies() { return probioticSpecies; }
+        public void setProbioticSpecies(List<Flora> probioticSpecies) { this.probioticSpecies = probioticSpecies; }
+        public List<FoodItem> getFoods() { return foods; }
+        public void setFoods(List<FoodItem> foods) { this.foods = foods; }
+        public List<DiseaseRisk> getDiseaseRisks() { return diseaseRisks; }
+        public void setDiseaseRisks(List<DiseaseRisk> diseaseRisks) { this.diseaseRisks = diseaseRisks; }
+    }
+}

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java

@@ -0,0 +1,61 @@
+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.util.Date;
+
+/**
+ * 健康报告上传草稿,用于两阶段入库:先解析暂存,用户确认后再正式写入健康报告表。
+ */
+@Data
+@TableName("health_report_drafts")
+public class HealthReportDraft implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 上传人用户ID */
+    private Long userId;
+
+    /** 家庭ID(可选,用于成员匹配) */
+    private Long familyId;
+
+    /** 报告类型: physical_exam / gut_flora */
+    private String reportType;
+
+    /** 上传的文件URL(PDF或图片) */
+    private String fileUrl;
+
+    /** 原始文件名 */
+    private String originalFilename;
+
+    /**
+     * 草稿载荷(JSON),包含:
+     * - 汇总分数 {overallScore, gutHealthScore, ...}
+     * - subject候选人 {personName, candidateMemberId, ...}
+     * - 指标列表 indicators: [{category, indicatorName, indicatorValue, unit, refRange, status}]
+     * - 菌群列表 gutFlora: [{bacteriaName, bacteriaValue, normalRange, populationLevel, detectionRate, category, level}]
+     * - 益生菌 probioticSpecies
+     * - 食材推荐 foods: [{name, category, score, energyKj, protein, fat, carbs, starch, fiber, cholesterol}]
+     * - 疾病风险 diseaseRisks: [{diseaseName, riskValue, riskLevel}]
+     * - 报告日期 reportDate
+     * - 报告编号 reportNumber
+     */
+    private String payloadJson;
+
+    /** 状态: pending(待确认)/confirmed(已确认入库)/discarded(用户放弃) */
+    private String status;
+
+    /** 草稿过期时间(创建时间+24h) */
+    private Date expireAt;
+
+    /** 确认后对应的真实报告ID */
+    private Long confirmedReportId;
+
+    private Date createdAt;
+    private Date updatedAt;
+}

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

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

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -58,4 +58,20 @@ public class FoodService {
                         .orderByAsc(Food::getSortOrder)
                         .orderByAsc(Food::getSortOrder)
         );
         );
     }
     }
+
+    /**
+     * 批量保存食材推荐(菌群报告食材推荐数据)
+     */
+    public void saveFoodsForReport(Long reportId, List<Food> foods) {
+        if (foods == null || foods.isEmpty()) return;
+        int sortOrder = 0;
+        for (Food food : foods) {
+            food.setReportId(reportId);
+            food.setSortOrder(sortOrder++);
+            food.setCreatedAt(new java.util.Date());
+            food.setUpdatedAt(new java.util.Date());
+            foodMapper.insert(food);
+        }
+        log.info("已保存{}条食材推荐到报告{}", foods.size(), reportId);
+    }
 }
 }

+ 103 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -0,0 +1,103 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 健康报告草稿服务 — 两阶段入库的中间态管理。
+ * 草稿保存时间默认 24 小时,过期后 confirm 接口拒绝。
+ */
+@Slf4j
+@Service
+public class HealthReportDraftService {
+
+    /** 草稿有效期(毫秒) */
+    public static final long DRAFT_TTL_MS = 24L * 60 * 60 * 1000;
+
+    @Resource
+    private HealthReportDraftMapper draftMapper;
+
+    /**
+     * 创建草稿。返回带 id 的实体。
+     */
+    public HealthReportDraft createDraft(Long userId, Long familyId, String reportType,
+                                         String fileUrl, String originalFilename, String payloadJson) {
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setUserId(userId);
+        draft.setFamilyId(familyId);
+        draft.setReportType(reportType);
+        draft.setFileUrl(fileUrl);
+        draft.setOriginalFilename(originalFilename);
+        draft.setPayloadJson(payloadJson);
+        draft.setStatus("pending");
+        Date now = new Date();
+        draft.setCreatedAt(now);
+        draft.setUpdatedAt(now);
+        draft.setExpireAt(new Date(now.getTime() + DRAFT_TTL_MS));
+        draftMapper.insert(draft);
+        log.info("草稿已创建: draftId={}, userId={}, reportType={}", draft.getId(), userId, reportType);
+        return draft;
+    }
+
+    /**
+     * 查询草稿(仅返回 pending 且未过期的)。
+     */
+    public HealthReportDraft getActiveDraft(Long draftId) {
+        HealthReportDraft draft = draftMapper.selectById(draftId);
+        if (draft == null) {
+            return null;
+        }
+        if (!"pending".equals(draft.getStatus())) {
+            return null;
+        }
+        if (draft.getExpireAt() != null && draft.getExpireAt().before(new Date())) {
+            return null;
+        }
+        return draft;
+    }
+
+    /**
+     * 标记草稿已确认(成功入库后调用)。
+     */
+    public void markConfirmed(Long draftId, Long reportId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getStatus, "confirmed")
+          .set(HealthReportDraft::getConfirmedReportId, reportId)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 标记草稿已丢弃。
+     */
+    public void markDiscarded(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getStatus, "discarded")
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 清理过期草稿(可由定时任务调用)。
+     */
+    public int purgeExpired() {
+        LambdaQueryWrapper<HealthReportDraft> qw = new LambdaQueryWrapper<>();
+        qw.lt(HealthReportDraft::getExpireAt, new Date())
+          .eq(HealthReportDraft::getStatus, "pending");
+        int count = draftMapper.selectCount(qw).intValue();
+        if (count > 0) {
+            draftMapper.delete(qw);
+            log.info("已清理 {} 条过期草稿", count);
+        }
+        return count;
+    }
+}

+ 18 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1445,6 +1445,24 @@ CREATE TABLE IF NOT EXISTS health_indicators (
     INDEX idx_category (category)
     INDEX idx_category (category)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标明细表';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标明细表';
 
 
+-- 健康报告上传草稿表(两阶段入库的中间态)
+CREATE TABLE IF NOT EXISTS health_report_drafts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT NOT NULL COMMENT '上传人用户ID',
+    family_id BIGINT COMMENT '家庭ID',
+    report_type VARCHAR(32) NOT NULL COMMENT '报告类型: physical_exam/gut_flora',
+    file_url VARCHAR(500) COMMENT '上传的文件URL',
+    original_filename VARCHAR(255) COMMENT '原始文件名',
+    payload_json MEDIUMTEXT NOT NULL COMMENT '草稿载荷JSON(含解析/手动录入的所有指标)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/confirmed/discarded',
+    expire_at DATETIME COMMENT '草稿过期时间',
+    confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_user_id (user_id),
+    INDEX idx_status_expire (status, expire_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告上传草稿表(两阶段入库中间态)';
+
 -- 教育体系配置表
 -- 教育体系配置表
 CREATE TABLE IF NOT EXISTS education_systems (
 CREATE TABLE IF NOT EXISTS education_systems (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',