Browse Source

feat(health-report): 健康报告收集功能 - ReportCollectService + draft 草稿 + 小程序上报页面

Xiaogang Liao 2 months ago
parent
commit
05e781be48

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

@@ -10254,5 +10254,15 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
             // 表已存在,忽略
         }
+
+        // 迁移288: health_report_drafts 加异步采集状态列(报告采集流程异步化)
+        // 用途:上传后进入后台异步采集(指纹判定→专用脚本/opencode),前端轮询 parse_status
+        ensureColumn("health_report_drafts", "parse_status",
+                "VARCHAR(20) DEFAULT NULL COMMENT '异步解析状态: collecting/completed/failed(NULL=未触发异步采集)'");
+        ensureColumn("health_report_drafts", "parse_method",
+                "VARCHAR(32) DEFAULT NULL COMMENT '解析方式: fingerprint_script/opencode/langgraph'");
+        ensureColumn("health_report_drafts", "parse_error",
+                "VARCHAR(2000) DEFAULT NULL COMMENT '异步解析失败原因'");
+        log.info("已添加 health_report_drafts 异步采集状态列 (parse_status/parse_method/parse_error)");
     }
 }

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

@@ -35,6 +35,7 @@ import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
+import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportBlockAssembler;
 import com.etotem.cfc.service.ReportBlockService;
 import com.etotem.cfc.service.StorageService;
@@ -162,6 +163,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.HealthPlanService healthPlanService;
 
+    @Resource
+    private ReportCollectService reportCollectService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -670,10 +674,15 @@ public class HealthReportController {
             HealthReportDraft draft = healthReportDraftService.createDraft(
                     userId, familyId, reportType, fileUrl, originalFilename, payloadJson);
 
+            // 提交异步采集:指纹判定类型后走专用脚本/opencode,完成自动入库,前端轮询 parse_status
+            reportCollectService.submitAsyncCollect(draft.getId());
+
             Map<String, Object> result = new LinkedHashMap<>();
             result.put("draftId", draft.getId());
             result.put("fileUrl", fileUrl);
             result.put("originalFilename", originalFilename);
+            result.put("parseStatus", ReportCollectService.STATUS_COLLECTING);
+            result.put("collecting", true);
             return Result.success(result);
 
         } catch (IOException e) {
@@ -682,6 +691,55 @@ public class HealthReportController {
         }
     }
 
+    /**
+     * 查询异步采集任务状态(前端轮询):返回 parse_status/parse_method/parse_error/reportId
+     */
+    @Operation(summary = "查询异步采集状态")
+    @PostMapping("/report/collect/status")
+    public Result<Map<String, Object>> collectStatus(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long draftId = ParamUtils.getLong(params.get("draftId"));
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+        HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在");
+        }
+        if (!draft.getUserId().equals(userId)) {
+            return Result.error("无权访问此草稿");
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("draftId", draftId);
+        result.put("parseStatus", draft.getParseStatus());
+        result.put("parseMethod", draft.getParseMethod());
+        result.put("parseError", draft.getParseError());
+        result.put("reportId", draft.getConfirmedReportId());
+        return Result.success(result);
+    }
+
+    /**
+     * 查询当前用户所有"采集中"的草稿(供报告列表顶部展示采集进度)
+     */
+    @Operation(summary = "查询采集中草稿列表")
+    @PostMapping("/report/collecting-list")
+    public Result<java.util.List<Map<String, Object>>> collectingList(
+            @RequestAttribute("userId") Long userId) {
+        java.util.List<HealthReportDraft> drafts = healthReportDraftService.listCollectingByUser(userId);
+        java.util.List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (HealthReportDraft d : drafts) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("draftId", d.getId());
+            item.put("originalFilename", d.getOriginalFilename());
+            item.put("reportType", d.getReportType());
+            item.put("createdAt", d.getCreatedAt());
+            item.put("parseStatus", d.getParseStatus());
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
     /**
      * 体脂秤图片上传 + AI 识别(一步完成:上传→识别→存库)
      */

+ 38 - 16
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java

@@ -1,8 +1,10 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportFingerprintService;
-import com.etotem.cfc.service.ReportParserDispatchService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.*;
@@ -10,6 +12,7 @@ import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
 import java.io.File;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.UUID;
 
@@ -23,30 +26,49 @@ public class ReportParserController {
     private ReportFingerprintService fingerprintService;
 
     @Resource
-    private ReportParserDispatchService dispatchService;
+    private ReportCollectService reportCollectService;
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private com.etotem.cfc.service.StorageService storageService;
 
     @PostMapping("/upload")
     public Result<Map<String, Object>> uploadAndParse(
             @RequestParam("file") MultipartFile file,
-            @RequestParam(value = "fileName", required = false) String fileName) {
+            @RequestParam(value = "fileName", required = false) String fileName,
+            @RequestAttribute("userId") Long userId) {
         if (file == null || file.isEmpty()) {
             return Result.error("请选择文件");
         }
 
         try {
-            String uploadDir = "/tmp/report_uploads";
-            File dir = new File(uploadDir);
-            if (!dir.exists()) dir.mkdirs();
-
-            String savedName = UUID.randomUUID().toString() + "_" +
-                    (fileName != null ? fileName : file.getOriginalFilename());
-            File savedFile = new File(dir, savedName);
-            file.transferTo(savedFile);
-
-            String pdfPath = savedFile.getAbsolutePath();
-            String fname = file.getOriginalFilename();
-            log.info("报告上传解析: file={}, path={}", fname, pdfPath);
-            return dispatchService.parseReport(pdfPath, fname);
+            String fileUrl = storageService.storeFile(file, "health-reports");
+            String originalFilename = fileName != null ? fileName : file.getOriginalFilename();
+
+            HealthReportDraft draft = healthReportDraftService.createDraft(
+                    userId, null, "gut_flora", fileUrl, originalFilename, "{}");
+
+            // 指纹检测(快,同步返回供调用方识别类型)
+            Map<String, Object> fpResult = null;
+            try {
+                fpResult = fingerprintService.detect(
+                        storageService.getFilePath(fileUrl), originalFilename);
+            } catch (Exception e) {
+                log.warn("指纹检测失败: {}", e.getMessage());
+            }
+
+            // 提交异步采集:已知→专用脚本 / 未知→opencode,完成自动入库
+            reportCollectService.submitAsyncCollect(draft.getId());
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("draftId", draft.getId());
+            result.put("fileUrl", fileUrl);
+            result.put("fingerprintResult", fpResult);
+            result.put("parseStatus", ReportCollectService.STATUS_COLLECTING);
+            result.put("collecting", true);
+            return Result.success(result);
 
         } catch (Exception e) {
             log.error("上传解析失败", e);

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

@@ -50,6 +50,15 @@ public class HealthReportDraft implements Serializable {
     /** 状态: pending(待确认)/confirmed(已确认入库)/discarded(用户放弃) */
     private String status;
 
+    /** 异步解析状态: collecting(采集中)/completed(完成)/failed(失败);NULL=未触发异步采集 */
+    private String parseStatus;
+
+    /** 解析方式: fingerprint_script/opencode/langgraph */
+    private String parseMethod;
+
+    /** 异步解析失败原因 */
+    private String parseError;
+
     /** 草稿过期时间(创建时间+24h) */
     private Date expireAt;
 

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -247,6 +247,48 @@ public class FamilyMemberService {
             result.add(vo);
         }
 
+        // ── 合并 users 表同家庭的家长账号 ──
+        // 家长账号在注册/创建家庭时只写入 users 表(family_id + role=parent),并不会同步到 family_members 表,
+        // 因此在成员列表中需要额外合并,否则家长在该家庭中永远缺失(如 family 72 只显示孩子却少一位家长)。
+        // 去重口径与 getVisibleMembers 一致:按 userId 去重,同一人若在 family_members 表已有记录则不再重复。
+        java.util.Set<Long> familyMemberUserIds = new java.util.HashSet<>();
+        for (FamilyMember fm : familyMembers) {
+            if (fm.getUserId() != null) {
+                familyMemberUserIds.add(fm.getUserId());
+            }
+        }
+        List<User> parents = userMapper.selectList(new LambdaQueryWrapper<User>()
+                .eq(User::getFamilyId, familyId)
+                .eq(User::getRole, "parent"));
+        for (User p : parents) {
+            if (p.getId() == null || familyMemberUserIds.contains(p.getId())) {
+                continue; // 已在 family_members 中,避免重复
+            }
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId(p.getId());
+            vo.setMemberId(p.getId());
+            vo.setSource("parent");
+            vo.setMemberType("parent");
+            vo.setUserId(p.getId());
+            vo.setNickname(p.getNickname());
+            vo.setName(p.getNickname());
+            vo.setAvatar(p.getAvatar());
+            vo.setPhone(p.getPhone());
+            vo.setGender(p.getGender());
+            vo.setEffectiveRole("parent");
+            vo.setRoleLabel(p.getFamilyRole() != null ? p.getFamilyRole() : "家长");
+            if (p.getBirthday() != null) {
+                vo.setAge(calculateAge(p.getBirthday()));
+            }
+            vo.setGeneration(0);
+            vo.setIsAdmin(isAdmin);
+            vo.setIsSelf(p.getId().equals(userId));
+            vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
+            vo.setIsSpouse(false);
+            vo.setTotalPoints(p.getTotalPoints());
+            result.add(vo);
+        }
+
         return result;
     }
 

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

@@ -46,6 +46,13 @@ public class HealthReportDraftService {
         return draft;
     }
 
+    /**
+     * 按主键查询草稿(无视状态/过期,供异步采集等内部逻辑使用)。
+     */
+    public HealthReportDraft getDraftById(Long draftId) {
+        return draftMapper.selectById(draftId);
+    }
+
     /**
      * 查询草稿(仅返回 pending 且未过期的)。
      */
@@ -118,4 +125,81 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
+
+    /**
+     * 标记异步解析状态(collecting/completed/failed)
+     */
+    public void updateParseStatus(Long draftId, String parseStatus) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, parseStatus)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 查询某用户仍处于"采集中"的草稿(供报告列表顶部展示进度)
+     */
+    public java.util.List<HealthReportDraft> listCollectingByUser(Long userId) {
+        if (userId == null) {
+            return java.util.Collections.emptyList();
+        }
+        LambdaQueryWrapper<HealthReportDraft> qw = new LambdaQueryWrapper<>();
+        qw.eq(HealthReportDraft::getUserId, userId)
+          .eq(HealthReportDraft::getParseStatus, "collecting")
+          .orderByDesc(HealthReportDraft::getCreatedAt)
+          .last("LIMIT 20");
+        return draftMapper.selectList(qw);
+    }
+
+    /**
+     * 异步解析开始时记录解析方式
+     */
+    public void markCollecting(Long draftId, String parseMethod) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, "collecting")
+          .set(HealthReportDraft::getParseMethod, parseMethod)
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 解析方式确定后单独更新(用于异步流程:先置 collecting,指纹判定后写具体方式)
+     */
+    public void updateParseMethod(Long draftId, String parseMethod) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseMethod, parseMethod)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 异步解析失败记录错误
+     */
+    public void markCollectFailed(Long draftId, String error) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        String safe = error != null && error.length() > 2000 ? error.substring(0, 2000) : error;
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, "failed")
+          .set(HealthReportDraft::getParseError, safe)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 异步解析完成 + 已入库,写入状态与关联报告ID
+     */
+    public void markCollectCompleted(Long draftId, Long reportId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, "completed")
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getStatus, "confirmed")
+          .set(HealthReportDraft::getConfirmedReportId, reportId)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
 }

+ 623 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -0,0 +1,623 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
+ * 解析完成自动入库,前端通过草稿 parse_status 轮询。
+ */
+@Slf4j
+@Service
+public class ReportCollectService {
+
+    @Resource
+    private ReportFingerprintService fingerprintService;
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private FoodService foodService;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    @Resource
+    private FoodRecommendService foodRecommendService;
+
+    @Resource
+    private ReportBlockService reportBlockService;
+
+    @Resource
+    private ReportBlockAssembler reportBlockAssembler;
+
+    @Resource
+    private StorageService storageService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource(name = "taskExecutor")
+    private Executor taskExecutor;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
+    private String opencodeBase;
+
+    @Value("${report.collect.opencode-user:opencode}")
+    private String opencodeUser;
+
+    @Value("${report.collect.opencode-password:IwinTrue@123}")
+    private String opencodePassword;
+
+    @Value("${report.collect.opencode-workdir:/app/cfc}")
+    private String opencodeWorkdir;
+
+    @Value("${report.collect.opencode-model-id:agnes-2.5-flash}")
+    private String opencodeModelId;
+
+    @Value("${report.collect.opencode-model-provider:agnes-ai}")
+    private String opencodeModelProvider;
+
+    /** 常量:解析方式 */
+    public static final String METHOD_FINGERPRINT = "fingerprint_script";
+    public static final String METHOD_OPENCODE = "opencode";
+
+    /** 状态常量:与草稿 parse_status 对齐 */
+    public static final String STATUS_COLLECTING = "collecting";
+    public static final String STATUS_COMPLETED = "completed";
+    public static final String STATUS_FAILED = "failed";
+
+    /**
+     * 提交异步采集任务。上传完成后调用,立即返回。
+     */
+    public void submitAsyncCollect(Long draftId) {
+        try {
+            taskExecutor.execute(() -> collect(draftId));
+        } catch (Exception e) {
+            log.error("提交异步采集任务失败 draftId={}", draftId, e);
+            healthReportDraftService.markCollectFailed(draftId, "提交任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 采集主流程:指纹判定 → 已知专用脚本 / 未知 opencode → 解析 JSON → 自动入库
+     */
+    public void collect(Long draftId) {
+        HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
+        if (draft == null) {
+            log.warn("异步采集草稿不存在 draftId={}", draftId);
+            return;
+        }
+        if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
+            return;
+        }
+        try {
+            healthReportDraftService.markCollecting(draftId, null);
+            String pdfPath = resolvePdfPath(draft);
+            if (pdfPath == null) {
+                healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
+                return;
+            }
+
+            // 指纹判定
+            Map<String, Object> fp = fingerprintService.detect(pdfPath, draft.getOriginalFilename());
+            String typeId = (String) fp.get("typeId");
+
+            ParsedReportPayload.Payload payload;
+            String method;
+            if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
+                method = METHOD_FINGERPRINT;
+                payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
+            } else {
+                method = METHOD_OPENCODE;
+                payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+            }
+
+            if (payload == null) {
+                healthReportDraftService.markCollectFailed(draftId, "解析结果为空或无效");
+                return;
+            }
+
+            harmReportPayloadValid(payload);
+            healthReportDraftService.updateParseMethod(draftId, method);
+            healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
+
+            insertReport(draft, payload);
+
+            log.info("异步采集完成 draftId={}, method={}", draftId, method);
+        } catch (Exception e) {
+            log.error("异步采集失败 draftId={}", draftId, e);
+            healthReportDraftService.markCollectFailed(draftId, e.getMessage());
+        }
+    }
+
+    private void harmReportPayloadValid(ParsedReportPayload.Payload payload) {
+        if (payload.getSummary() == null && (payload.getIndicators() == null || payload.getIndicators().isEmpty())
+                && (payload.getGutFlora() == null || payload.getGutFlora().isEmpty())) {
+            throw new IllegalArgumentException("解析结果中未提取到有效指标数据");
+        }
+    }
+
+    private String resolvePdfPath(HealthReportDraft draft) {
+        if (draft.getFileUrl() == null) {
+            return null;
+        }
+        try {
+            String p = storageService.getFilePath(draft.getFileUrl());
+            File f = new File(p);
+            return f.exists() ? f.getAbsolutePath() : null;
+        } catch (Exception e) {
+            log.warn("解析文件路径失败 fileUrl={}", draft.getFileUrl(), e);
+            return null;
+        }
+    }
+
+    /** 已知类型:调专用解析器脚本,期望输出符合 ParsedReportPayload 的 JSON */
+    private ParsedReportPayload.Payload parseWithDedicatedScript(String pdfPath, String parserPath) {
+        Map<String, Object> parsed = fingerprintService.runParser(parserPath, pdfPath);
+        if (parsed == null) {
+            return null;
+        }
+        try {
+            JsonNode node = objectMapper.valueToTree(parsed);
+            return normalizeToPayload(node);
+        } catch (Exception e) {
+            log.warn("专用脚本输出转载荷失败 parserPath={}", parserPath, e);
+            return null;
+        }
+    }
+
+    /** 未知类型:交本地 opencode 服务,提示词带 PDF 内容(后端先 pdftotext 提取文本内联),期望返回结构化 JSON */
+    private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
+        String pdfText = extractPdfText(pdfPath);
+        String sessionId = opencodeCreateSession();
+        String prompt = "下面是健康检测报告(文件名:" + (fileName != null ? fileName : pdfPath)
+                + ")用 pdftotext 提取出的全文文本。请从中提取健康指标数据,并仅返回一个严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n"
+                + "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\"},\n"
+                + "\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}],\n"
+                + "\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\n"
+                + "\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}],\n"
+                + "\"foods\":[]}\n"
+                + "报告全文文本如下:\n" + pdfText + "\n"
+                + "若无法提取某部分数据返回空数组或 null。";
+        String reply = opencodeSendAndWait(sessionId, prompt);
+        if (reply == null || reply.trim().isEmpty()) {
+            return null;
+        }
+        String json = extractJson(reply);
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            return normalizeToPayload(node);
+        } catch (Exception e) {
+            log.warn("opencode 输出转载荷失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** 用 pdftotext 提取 PDF 文本,失败时返回可读提示(避免模型误读图片型 PDF) */
+    private String extractPdfText(String pdfPath) {
+        try {
+            File file = new File(pdfPath);
+            if (!file.exists() || !file.isFile()) {
+                return "PDF 文件不存在或不是文件:" + pdfPath;
+            }
+            ProcessBuilder pb = new ProcessBuilder("pdftotext", "-layout", pdfPath, "-");
+            pb.redirectErrorStream(true);
+            Process process = pb.start();
+            StringBuilder sb = new StringBuilder();
+            try (BufferedReader reader = new BufferedReader(
+                    new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    sb.append(line).append('\n');
+                }
+            }
+            if (!process.waitFor(30, TimeUnit.SECONDS)) {
+                process.destroyForcibly();
+                log.warn("pdftotext 超时 pdfPath={}", pdfPath);
+                return "PDF 文本提取超时。" + pdfPath;
+            }
+            String text = sb.toString();
+            if (text.trim().isEmpty()) {
+                return "该 PDF 可能为扫描图片,无可用文本。" + pdfPath;
+            }
+            // 控制提示词体积:仅保留前 60k 字符,超长截断
+            return text.length() > 60000 ? text.substring(0, 60000) : text;
+        } catch (Exception e) {
+            log.warn("pdftotext 提取失败 pdfPath={}: {}", pdfPath, e.getMessage());
+            return "PDF 文本提取失败:" + e.getMessage();
+        }
+    }
+
+    /** 从回复文本中提取 JSON(容忍 code fence 或前导说明) */
+    private String extractJson(String reply) {
+        String s = reply.trim();
+        int start = s.indexOf('{');
+        int end = s.lastIndexOf('}');
+        if (start >= 0 && end > start) {
+            return s.substring(start, end + 1);
+        }
+        return s;
+    }
+
+    /** 将任意 JSON 节点归一化为 ParsedReportPayload.Payload */
+    private ParsedReportPayload.Payload normalizeToPayload(JsonNode node) {
+        return objectMapper.convertValue(node, ParsedReportPayload.Payload.class);
+    }
+
+    /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
+    private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
+        Long subjectId = null;
+        if (draft.getFamilyId() != null && payload.getSummary() != null
+                && payload.getSummary().getPersonName() != null) {
+            subjectId = healthReportService.matchFamilyMemberByName(
+                    payload.getSummary().getPersonName(), draft.getFamilyId());
+        }
+
+        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);
+
+        try {
+            healthReportService.savePayloadSnapshot(created.getId(), draft.getUserId(), "create", null,
+                    objectMapper.writeValueAsString(payload));
+        } catch (Exception e) {
+            log.warn("保存载荷快照失败 reportId={}", created.getId(), e);
+        }
+
+        if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
+            try {
+                List<Food> foods = buildFoodsFromPayload(payload);
+                foodService.saveFoodsForReport(created.getId(), foods);
+            } catch (Exception e) {
+                log.warn("保存食材推荐失败 reportId={}", created.getId(), e);
+            }
+        }
+
+        if (subjectId != null) {
+            healthReportService.bindReportToMember(created.getId(), subjectId);
+            try {
+                dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
+            } catch (Exception e) {
+                log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
+            }
+            try {
+                foodRecommendService.recalculateForUser(subjectId);
+            } catch (Exception e) {
+                log.warn("刷新食材推荐指数失败 memberId={}", subjectId, e);
+            }
+        }
+
+        try {
+            reportBlockService.save(created.getReportType(), created.getId(),
+                    reportBlockAssembler.assembleWithTemplate(created.getReportType(), payload));
+        } catch (Exception e) {
+            log.warn("保存展示块失败 reportId={}", created.getId(), e);
+        }
+
+        healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
+    }
+
+    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.setShannonIndex(s.getShannonIndex());
+            report.setReportNumber(s.getReportNumber());
+            report.setPersonName(s.getPersonName());
+            report.setReportDate(parseDate(s.getReportDate()));
+        }
+        if (report.getReportDate() == null) {
+            report.setReportDate(new Date());
+        }
+        report.setRemark("异步采集自动入库");
+        return report;
+    }
+
+    private Date parseDate(String dateStr) {
+        if (dateStr == null || dateStr.isEmpty()) {
+            return null;
+        }
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            return sdf.parse(dateStr);
+        } catch (Exception e) {
+            try {
+                SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy/MM/dd");
+                return sdf2.parse(dateStr);
+            } catch (Exception e2) {
+                return null;
+            }
+        }
+    }
+
+    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);
+            ind.setCollectionDate(parseDate(pi.getCollectionDate()));
+            indicators.add(ind);
+        }
+        return indicators;
+    }
+
+    private List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
+        List<HealthGutFlora> result = new ArrayList<>();
+        addFloraList(result, payload.getGutFlora());
+        addFloraList(result, payload.getProbioticSpecies());
+        addFloraList(result, payload.getTaxonomyClass());
+        addFloraList(result, payload.getTaxonomyOrder());
+        addFloraList(result, payload.getTaxonomyFamily());
+        addFloraList(result, payload.getTaxonomyGenus());
+        addFloraList(result, payload.getTaxonomySpecies());
+        addFloraList(result, payload.getPathogenGenus());
+        addFloraList(result, payload.getPathogenDetection());
+        return result;
+    }
+
+    private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
+        if (source != null) {
+            for (ParsedReportPayload.Flora pf : source) {
+                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(normalizeFloraCategory(pf.getCategory()));
+                gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
+                target.add(gf);
+            }
+        }
+    }
+
+    private String normalizeFloraCategory(String category) {
+        if (category == null) return null;
+        if (category.endsWith("构成")) {
+            return HealthGutFlora.CATEGORY_TAXONOMY;
+        }
+        return category;
+    }
+
+    private String normalizeFloraLevel(String category, String level) {
+        if (level == null) return null;
+        String normalized = level;
+        if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
+        else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
+        else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
+        else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
+        else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
+        else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
+        return normalized;
+    }
+
+    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;
+    }
+
+    // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
+
+    private String opencodeCreateSession() {
+        try {
+            String url = opencodeBase + "/session?directory="
+                    + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("title", "report-collect-" + System.currentTimeMillis());
+            body.put("agent", "report-collect");
+            // 显式固定模型(Provider/Model id 在 body 中传递,服务端才尊重;query 参数会被忽略)
+            Map<String, Object> model = new LinkedHashMap<>();
+            model.put("id", opencodeModelId);
+            model.put("providerID", opencodeModelProvider);
+            body.put("model", model);
+            ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST,
+                    new HttpEntity<>(body, opencodeHeaders()), Map.class);
+            Map<?, ?> data = resp.getBody();
+            if (data == null || data.get("id") == null) {
+                throw new IllegalStateException("opencode 创建会话未返回 session_id");
+            }
+            return data.get("id").toString();
+        } catch (Exception e) {
+            throw new IllegalStateException("opencode 创建会话失败: " + e.getMessage(), e);
+        }
+    }
+
+    /** 发送提示词并轮询取回 assistant 最终文本回复 */
+    private String opencodeSendAndWait(String sessionId, String prompt) {
+        HttpHeaders headers = opencodeHeaders();
+        List<Map<String, Object>> parts = new ArrayList<>();
+        Map<String, Object> part = new LinkedHashMap<>();
+        part.put("type", "text");
+        part.put("text", prompt);
+        parts.add(part);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("parts", parts);
+        // 只读约束:仅允许分析/读取,禁用执行/写入类工具(与本地运营脚本对齐)
+        Map<String, Object> tools = new LinkedHashMap<>();
+        for (String t : new String[]{"bash", "shell", "write", "edit", "apply_patch",
+                "task", "dispatch", "webfetch_post", "chrome_launch"}) {
+            tools.put(t, Boolean.FALSE);
+        }
+        body.put("tools", tools);
+        restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
+                HttpMethod.POST, new HttpEntity<>(body, headers), Void.class);
+
+        long deadline = System.currentTimeMillis() + 180000;
+        long pollInterval = 5000;
+        int before = countMessages(sessionId);
+        while (System.currentTimeMillis() < deadline) {
+            try {
+                Thread.sleep(pollInterval);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return null;
+            }
+            List<Map<String, Object>> msgs = listMessages(sessionId);
+            if (msgs == null || msgs.isEmpty()) {
+                continue;
+            }
+            Map<String, Object> last = msgs.get(msgs.size() - 1);
+            String role = roleOf(last);
+            if ("assistant".equals(role)) {
+                String text = lastAssistantText(last);
+                if (text != null && !text.trim().isEmpty()) {
+                    return text;
+                }
+            }
+            if (msgs.size() > before) {
+                for (int i = msgs.size() - 1; i >= 0; i--) {
+                    String r = roleOf(msgs.get(i));
+                    if ("assistant".equals(r)) {
+                        String t = lastAssistantText(msgs.get(i));
+                        if (t != null && !t.trim().isEmpty()) {
+                            return t;
+                        }
+                    }
+                }
+            }
+        }
+        throw new IllegalStateException("等待 opencode 回复超时(180s)");
+    }
+
+    @SuppressWarnings("unchecked")
+    private String roleOf(Map<String, Object> msg) {
+        Map<String, Object> info = (Map<String, Object>) msg.get("info");
+        return info != null ? (String) info.get("role") : null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String lastAssistantText(Map<String, Object> msg) {
+        Object partsObj = msg.get("parts");
+        if (!(partsObj instanceof List)) return null;
+        List<Object> parts = (List<Object>) partsObj;
+        String text = null;
+        for (Object pObj : parts) {
+            if (pObj instanceof Map) {
+                Map<String, Object> p = (Map<String, Object>) pObj;
+                if ("text".equals(p.get("type")) && p.get("text") != null) {
+                    text = p.get("text").toString();
+                }
+            }
+        }
+        return text;
+    }
+
+    private int countMessages(String sessionId) {
+        List<Map<String, Object>> msgs = listMessages(sessionId);
+        return msgs == null ? 0 : msgs.size();
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> listMessages(String sessionId) {
+        try {
+            ResponseEntity<List> resp = restTemplate.exchange(
+                    opencodeBase + "/session/" + sessionId + "/message",
+                    HttpMethod.GET, new HttpEntity<>(opencodeHeaders()), List.class);
+            return resp.getBody();
+        } catch (Exception e) {
+            log.warn("opencode 查询消息失败 session={}", sessionId, e);
+            return null;
+        }
+    }
+
+    private HttpHeaders opencodeHeaders() {
+        String token = Base64.getEncoder().encodeToString(
+                (opencodeUser + ":" + opencodePassword).getBytes(StandardCharsets.UTF_8));
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Basic " + token);
+        return headers;
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -1343,6 +1343,9 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
 
+        // 4. 创建人自己也是家庭成员(generation=0 表示创建者),保证 family_members 表与 users 表一致
+        addCreatorAsMember(user, family.getId());
+
         log.info("已为用户 {} 自动创建家庭 {} ({})", user.getId(), family.getId(), familyName);
         return user;
     }

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

@@ -1799,6 +1799,9 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     original_filename VARCHAR(255) COMMENT '原始文件名',
     payload_json MEDIUMTEXT NOT NULL COMMENT '草稿载荷JSON(含解析/手动录入的所有指标)',
     status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/confirmed/discarded',
+    parse_status VARCHAR(20) DEFAULT NULL COMMENT '异步解析状态: collecting/completed/failed(NULL=未触发异步采集)',
+    parse_method VARCHAR(32) DEFAULT NULL COMMENT '解析方式: fingerprint_script/opencode/langgraph',
+    parse_error VARCHAR(2000) DEFAULT NULL COMMENT '异步解析失败原因',
     expire_at DATETIME COMMENT '草稿过期时间',
     confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
     remark VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)',

+ 114 - 1
cfc-frontend/pages/health/report-list.vue

@@ -20,6 +20,20 @@
       </scroll-view>
     </view>
 
+    <!-- 采集中草稿 -->
+    <view class="collecting-wrap" v-if="collectingReports.length > 0">
+      <view class="collecting-card" v-for="item in collectingReports" :key="item.draftId">
+        <view class="collecting-icon">
+          <view class="spinner"></view>
+        </view>
+        <view class="collecting-info">
+          <text class="collecting-title">数据采集中</text>
+          <text class="collecting-file">{{ item.originalFilename || '健康报告' }}</text>
+        </view>
+        <text class="collecting-status">解析完成后自动入库</text>
+      </view>
+    </view>
+
     <!-- 加载状态 -->
     <view class="loading-wrap" v-if="loading">
       <text class="loading-text">加载中...</text>
@@ -72,7 +86,7 @@
 </template>
 
 <script>
-import { getReportList, deleteReport, getFamilyMemberList } from '../../utils/api.js'
+import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -83,6 +97,9 @@ export default {
       selectedMemberId: null,
       loading: false,
       memberId: null,
+      // 异步采集中的草稿
+      collectingReports: [],
+      collectTimer: null,
       // 左滑删除
       openMap: {},
       curId: null,
@@ -99,8 +116,43 @@ export default {
     this.selectedMemberId = this.memberId
     this.loadMembers()
     this.loadData()
+    this.startCollectPolling()
+  },
+  onShow() {
+    // 从报告上传页返回时刷新列表,展示后台异步采集完成/失败的报告
+    if (this._pageShown) {
+      this.loadData()
+      this.loadCollectingReports()
+    }
+    this._pageShown = true
+  },
+  onUnload() {
+    if (this.collectTimer) {
+      clearInterval(this.collectTimer)
+      this.collectTimer = null
+    }
   },
   methods: {
+    startCollectPolling() {
+      var self = this
+      if (self.collectTimer) {
+        clearInterval(self.collectTimer)
+      }
+      self.loadCollectingReports()
+      self.collectTimer = setInterval(function() {
+        self.loadCollectingReports()
+      }, 6000)
+    },
+    loadCollectingReports() {
+      var self = this
+      getCollectingReports().then(function(res) {
+        if (res.code === 200) {
+          self.collectingReports = res.data || []
+        }
+      }).catch(function() {
+        // 采集进度轮询失败静默,下一轮重试
+      })
+    },
     loadMembers() {
       var self = this
       getFamilyMemberList({}).then(function(res) {
@@ -296,6 +348,67 @@ export default {
   color: #999;
 }
 
+/* ===== 采集中草稿 ===== */
+.collecting-wrap {
+  margin: 20rpx 30rpx 0;
+}
+.collecting-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background: #FFF8E6;
+  border: 1rpx solid #F5D78E;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+}
+.collecting-icon {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: #F0B429;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+}
+.spinner {
+  width: 28rpx;
+  height: 28rpx;
+  border: 4rpx solid rgba(255,255,255,0.4);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: collecting-spin 1s linear infinite;
+}
+@keyframes collecting-spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+.collecting-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.collecting-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #7A5C00;
+}
+.collecting-file {
+  font-size: 24rpx;
+  color: #A08A45;
+  margin-top: 4rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.collecting-status {
+  font-size: 22rpx;
+  color: #C0A75A;
+  flex-shrink: 0;
+}
+
 /* ===== 报告列表 ===== */
 .report-list {
   margin: 20rpx 30rpx;

+ 38 - 4
cfc-frontend/pages/health/report-upload.vue

@@ -177,7 +177,7 @@
 </template>
 
 <script>
-import { uploadReportOnly, danParsePreview } from '../../utils/api.js'
+import { uploadReportOnly, danParsePreview, getReportCollectStatus } from '../../utils/api.js'
 import config from '@/config.js'
 
 var DAN_DIMENSIONS = [
@@ -205,7 +205,9 @@ export default {
       danDimension: 'mind',
       danDimensions: DAN_DIMENSIONS,
       showKindSheet: false,
-      pendingIsPhoto: false
+      pendingIsPhoto: false,
+      collectDraftId: null,
+      collectTimer: null
     }
   },
   computed: {
@@ -242,6 +244,12 @@ export default {
       } catch (e) {}
     }
   },
+  onUnload: function() {
+    if (this.collectTimer) {
+      clearInterval(this.collectTimer)
+      this.collectTimer = null
+    }
+  },
   methods: {
     selectReportKind: function(kind) {
       this.reportKind = kind
@@ -376,7 +384,7 @@ export default {
         }
       })
     },
-    // 健康报告:upload-only → 菌群/健康解析页
+    // 健康报告:upload-only → 后台异步采集(指纹判定→脚本/opencode),完成后自动入库
     uploadHealthAndGoConfirm: function(filePath, type) {
       if (!filePath) return
       var self = this
@@ -390,7 +398,9 @@ export default {
           uni.showToast({ title: (res && res.message) || '上传失败', icon: 'none' })
           return
         }
-        uni.redirectTo({ url: '/pages/health/gut-flora-detail?draftId=' + draftId })
+        self.collectDraftId = draftId
+        uni.showToast({ title: '数据采集中,完成后可在报告列表查看', icon: 'none', duration: 3000 })
+        self.startCollectPolling(draftId)
       }).catch(function(err) {
         uni.hideLoading()
         self.uploading = false
@@ -398,6 +408,30 @@ export default {
         uni.showToast({ title: msg, icon: 'none' })
       })
     },
+    // 轮询采集状态,完成后仅提示,用户可前往报告列表查看
+    startCollectPolling: function(draftId) {
+      var self = this
+      if (self.collectTimer) {
+        clearInterval(self.collectTimer)
+      }
+      self.collectTimer = setInterval(function() {
+        getReportCollectStatus(draftId).then(function(res) {
+          var data = res.data || {}
+          var status = data.parseStatus
+          if (status === 'completed' || status === 'failed') {
+            clearInterval(self.collectTimer)
+            self.collectTimer = null
+            if (status === 'completed') {
+              uni.showToast({ title: '报告已采集完成', icon: 'success', duration: 2000 })
+            } else {
+              uni.showToast({ title: (data.parseError || '报告采集失败,请重试'), icon: 'none', duration: 3000 })
+            }
+          }
+        }).catch(function() {
+          // 轮询失败静默,下次继续
+        })
+      }, 4000)
+    },
     // DAN测评:parse-preview → 预览确认页
     uploadDanAndGoPreview: function(filePath) {
       if (!filePath) return

+ 6 - 0
cfc-frontend/utils/api.js

@@ -2186,6 +2186,12 @@ export const uploadReportOnly = (filePath, type) => {
 
 export const confirmReportPreview = (data) => request('/api/health/report/confirm', 'POST', data)
 
+// 查询异步采集状态(数据采集中→完成后自动入库)
+export const getReportCollectStatus = (draftId) => request('/api/health/report/collect/status', 'POST', { draftId })
+
+// 查询当前用户所有"采集中"的草稿
+export const getCollectingReports = () => request('/api/health/report/collecting-list', 'POST', {})
+
 // 放弃草稿
 export const discardReportDraft = (draftId) => request('/api/health/report/discard', 'POST', { draftId })
 

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-01c69099c0e66178a73d12568b666384bc7ffc52
+ac92a07e5cd99711b761918bf37252c520103136

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1296",
+  "version": "1.0.1283",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 4 - 141
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,151 +4,14 @@
 
 ---
 
-## v1.0.1296 (2026-09-03)
-
-### 其他
-- 知识中心-知识分类,修复视频上传异常问题
-
-
-## v1.0.1295 (2026-09-03)
-
-### 其他
-- 知识中心-知识分类,修复视频上传异常问题
-- 知识中心-知识分类,修复视频上传异常问题
-
-
-## v1.0.1294 (2026-09-03)
-
-### 新功能
-- 能量沙盘个人/全家切换移到右上角,单成员时弹提示引导添加
-
-
-## v1.0.1293 (2026-09-03)
-
-### Bug 修复
-- 补全 reportFormat 字段到 Payload DTO 和 HealthReport 实体,修复编译失败
-
-
-## v1.0.1292 (2026-09-03)
-
-### 其他
-- 修复视频上传异常问题
-
-
-## v1.0.1291 (2026-09-03)
-
-### 其他
-- 优化修复
-- - FoodRecommendService: add missing import java.math.BigDecimal
-- - HealthReportController: remove undefined 'parsed' reference in buildPayloadFromLgData
-- 
-
-### Bug 修复
-- self-check-result :key 移除字符串拼接表达式 'ma'+i,避免非 h5 平台编译警告
-
-### 新功能
-- extend search to title+summary+tags; fix compile errors in FoodRecommendService and HealthReportController
-
-
-## v1.0.1290 (2026-09-03)
-
-### 新功能
-- 家庭运营增加优惠券列表和使用记录(后端/family-coupons + /family-coupon-usage,前端弹窗展示)
-
-
-## v1.0.1289 (2026-09-03)
-
-### Bug 修复
-- processGifts 下单时复核赠品商品库存
-- 修复 toggleGift 后多余逗号语法错误
-- class 改用 _selected 状态字段,移除方法调用
+## v1.0.1283 (2026-09-02)
 
 ### 新功能
-- 管理端商品编辑页新增可选赠品配置区块
-- 小程序商品详情赠品选择区 + checkout 透传 giftItemIds
-- createMultiItem 单商品路径支持赠品(抽取 processGifts 共享方法)
-- handlePaymentSuccess 追加赠品发放逻辑
-- create 流程注入赠品校验与 product_order_gifts 记录
-- 新增管理端赠品配置接口(/api/admin/product-gift/save + /list)
-- 新增 ProductGiftService(配置保存/查询逻辑)
-- 新增三张赠品表实体类与Mapper接口
-- 建三张赠品相关表迁移(product_gift_configs/items/order_gifts)
-
-### 其他
-- feat(product-gift): 商品详情附带当前用户等级赠品配置
-- - 涵盖:数据库迁移、实体/Mapper、ProductGiftService、管理端接口、
--   下单校验+写记录、支付成功发放、商品详情装配、小程序前端、管理端前端
-- 
--   - 3张新表:product_gift_configs / product_gift_items / product_order_gifts
--   - 与现有 product_gift_rules(赠送会员)完全独立
--   - 赠品类型:商品 或 CF值,按会员等级配置,用户可多选
--   - 下单时写入订单赠品记录(fulfilled=0),支付成功后发放
-- - 更新 PROJECT-OVERVIEW.md §4.2 电商与供应体系,记录新设计稿
-- 
-
-### 文档
-- 商品可选赠品功能实施计划
-- 商品可选赠品功能设计稿(product-gift-selection-design)
-
-
-## v1.0.1288 (2026-09-03)
-
-### Bug 修复
-- 移除能量沙盘切换开关的 familyUnlocked 门禁条件
-
-### 其他
-- 切换开关仅依赖 hasMultipleMembers(有 2+ 成员时显示),
-- familyUnlocked 仍作为 prop 保留,不影响 GateLock 门禁展示。
-- 
-
-
-## v1.0.1287 (2026-09-03)
-
-### Bug 修复
-- 根治 family/member/list 与 tasks/history 重复调用——index-home loadFamilyFeed 改为只查第一个孩子、parent-index/child-index 合并 getChildren+loadFamilyMembersVisible 为单次 getFamilyMemberList
-
-### 其他
-- - child-index: loadData 中 getChildren() 改为 getFamilyMemberList({visibleOnly:true}) 一次调用,同步设置 familyMembersVisible,跳过后续 loadFamilyMembersVisible()
-- - parent-index: dashboard 成功路径同步设置 familyMembersVisible + _membersVisibleSet,降级路径合并 getFamilyMemberList+getChildren 为单次调用并派生 children
-- 
-
-
-## v1.0.1286 (2026-09-02)
+- 饮食推荐幂等保存(同日同mealType更新)+ 前端配套 + bump
 
 ### Bug 修复
-- 根治重复API调用——首页合并 family/member/list 与 membership/my、全页面 onLoad/onShow 防双发 + 请求层 3 秒去重 + getChildren 废弃委托
-
-### 其他
-- - 首页 index-home: checkMemberStatus 移入 loggedInInitDone 守卫,onLoad/onShow 用 _onLoadFired 防首次双发
-- - 首页 index-home: loadFamilyFeed 循环前过滤孩子成员,避免对家长发起 getTaskHistory
-- - 全页面: energy/hierarchy/share/survey-history/parent-index/join/notification/onboarding/address/cart/checkout/shop-index/daily-tasks/service-tasks/teacher-home/wealth-cf 统一 _onLoadFired 模式
-- - parent-index: dashboard 降级路径同步设置 familyMembersVisible,避免重复请求 getFamilyMemberList
-- - utils/api.js: 全局请求去重(同 URL+method+body 3 秒窗口复用 Promise),401 重放跳过去重
-- - utils/api.js: getChildren/getUserBadges/getGrowthReport/getTaskHistoryByCategory 等别名统一委托规范函数
-- - 新增 scripts/audit-duplicate-api-calls.js CI 审计脚本(0 error)
-- - AGENTS.md: 新增规则 1-4 + 4a/4b/4c(生命周期双发、同接口单封装、N+1 过滤、getChildren 废弃)
-- 
-
-
-## v1.0.1285 (2026-09-02)
-
-### 新功能
-- 优化谷禾菌群报告详情页展示与饮食推荐
-
-### 其他
-- - 疾病风险内联展示并颜色区分(注意/低风险),修复详情页与风险页统计不一致
-- - 疾病风险页知识库入口改为 ? 图标放疾病名之前
-- - 菌群详情页修复分类 tab 过滤(中文 category 映射英文 key)
-- - 菌群风险分组(肥胖/抑郁/过敏等)改手风琴展示
-- - 饮食推荐页:去重标题、按推荐值分级背景色(<-30红/>30绿/每10一档)、去蛋白能量固定信息、增加食材搜索、复用知识库弹窗
-- - 成长页饮食推荐复用食材页,不传 reportId 取最新建议
-- 
-
-
-## v1.0.1284 (2026-09-02)
-
-### 重构
-- 清理遗留项(未用注入/静默catch/Set去重/UUID导入/参数校验/Javadoc)
+- 移除 ProductEdit.vue 中误粘贴的 AI 规划 Markdown 文本,修复构建失败
+- 健康报告列表删除按钮改为左滑出现,修复未注册组件导致按钮显示在行上方
 
 
 ## v1.0.1282 (2026-09-02)

+ 5 - 142
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1296
+> 当前版本: v1.0.1283
 
 ## 历史版本
 
@@ -8,151 +8,14 @@
 
 ---
 
-## v1.0.1296 (2026-09-03)
-
-### 其他
-- 知识中心-知识分类,修复视频上传异常问题
-
-
-## v1.0.1295 (2026-09-03)
-
-### 其他
-- 知识中心-知识分类,修复视频上传异常问题
-- 知识中心-知识分类,修复视频上传异常问题
-
-
-## v1.0.1294 (2026-09-03)
-
-### 新功能
-- 能量沙盘个人/全家切换移到右上角,单成员时弹提示引导添加
-
-
-## v1.0.1293 (2026-09-03)
-
-### Bug 修复
-- 补全 reportFormat 字段到 Payload DTO 和 HealthReport 实体,修复编译失败
-
-
-## v1.0.1292 (2026-09-03)
-
-### 其他
-- 修复视频上传异常问题
-
-
-## v1.0.1291 (2026-09-03)
-
-### 其他
-- 优化修复
-- - FoodRecommendService: add missing import java.math.BigDecimal
-- - HealthReportController: remove undefined 'parsed' reference in buildPayloadFromLgData
-- 
-
-### Bug 修复
-- self-check-result :key 移除字符串拼接表达式 'ma'+i,避免非 h5 平台编译警告
-
-### 新功能
-- extend search to title+summary+tags; fix compile errors in FoodRecommendService and HealthReportController
-
-
-## v1.0.1290 (2026-09-03)
-
-### 新功能
-- 家庭运营增加优惠券列表和使用记录(后端/family-coupons + /family-coupon-usage,前端弹窗展示)
-
-
-## v1.0.1289 (2026-09-03)
-
-### Bug 修复
-- processGifts 下单时复核赠品商品库存
-- 修复 toggleGift 后多余逗号语法错误
-- class 改用 _selected 状态字段,移除方法调用
+## v1.0.1283 (2026-09-02)
 
 ### 新功能
-- 管理端商品编辑页新增可选赠品配置区块
-- 小程序商品详情赠品选择区 + checkout 透传 giftItemIds
-- createMultiItem 单商品路径支持赠品(抽取 processGifts 共享方法)
-- handlePaymentSuccess 追加赠品发放逻辑
-- create 流程注入赠品校验与 product_order_gifts 记录
-- 新增管理端赠品配置接口(/api/admin/product-gift/save + /list)
-- 新增 ProductGiftService(配置保存/查询逻辑)
-- 新增三张赠品表实体类与Mapper接口
-- 建三张赠品相关表迁移(product_gift_configs/items/order_gifts)
-
-### 其他
-- feat(product-gift): 商品详情附带当前用户等级赠品配置
-- - 涵盖:数据库迁移、实体/Mapper、ProductGiftService、管理端接口、
--   下单校验+写记录、支付成功发放、商品详情装配、小程序前端、管理端前端
-- 
--   - 3张新表:product_gift_configs / product_gift_items / product_order_gifts
--   - 与现有 product_gift_rules(赠送会员)完全独立
--   - 赠品类型:商品 或 CF值,按会员等级配置,用户可多选
--   - 下单时写入订单赠品记录(fulfilled=0),支付成功后发放
-- - 更新 PROJECT-OVERVIEW.md §4.2 电商与供应体系,记录新设计稿
-- 
-
-### 文档
-- 商品可选赠品功能实施计划
-- 商品可选赠品功能设计稿(product-gift-selection-design)
-
-
-## v1.0.1288 (2026-09-03)
-
-### Bug 修复
-- 移除能量沙盘切换开关的 familyUnlocked 门禁条件
-
-### 其他
-- 切换开关仅依赖 hasMultipleMembers(有 2+ 成员时显示),
-- familyUnlocked 仍作为 prop 保留,不影响 GateLock 门禁展示。
-- 
-
-
-## v1.0.1287 (2026-09-03)
-
-### Bug 修复
-- 根治 family/member/list 与 tasks/history 重复调用——index-home loadFamilyFeed 改为只查第一个孩子、parent-index/child-index 合并 getChildren+loadFamilyMembersVisible 为单次 getFamilyMemberList
-
-### 其他
-- - child-index: loadData 中 getChildren() 改为 getFamilyMemberList({visibleOnly:true}) 一次调用,同步设置 familyMembersVisible,跳过后续 loadFamilyMembersVisible()
-- - parent-index: dashboard 成功路径同步设置 familyMembersVisible + _membersVisibleSet,降级路径合并 getFamilyMemberList+getChildren 为单次调用并派生 children
-- 
-
-
-## v1.0.1286 (2026-09-02)
+- 饮食推荐幂等保存(同日同mealType更新)+ 前端配套 + bump
 
 ### Bug 修复
-- 根治重复API调用——首页合并 family/member/list 与 membership/my、全页面 onLoad/onShow 防双发 + 请求层 3 秒去重 + getChildren 废弃委托
-
-### 其他
-- - 首页 index-home: checkMemberStatus 移入 loggedInInitDone 守卫,onLoad/onShow 用 _onLoadFired 防首次双发
-- - 首页 index-home: loadFamilyFeed 循环前过滤孩子成员,避免对家长发起 getTaskHistory
-- - 全页面: energy/hierarchy/share/survey-history/parent-index/join/notification/onboarding/address/cart/checkout/shop-index/daily-tasks/service-tasks/teacher-home/wealth-cf 统一 _onLoadFired 模式
-- - parent-index: dashboard 降级路径同步设置 familyMembersVisible,避免重复请求 getFamilyMemberList
-- - utils/api.js: 全局请求去重(同 URL+method+body 3 秒窗口复用 Promise),401 重放跳过去重
-- - utils/api.js: getChildren/getUserBadges/getGrowthReport/getTaskHistoryByCategory 等别名统一委托规范函数
-- - 新增 scripts/audit-duplicate-api-calls.js CI 审计脚本(0 error)
-- - AGENTS.md: 新增规则 1-4 + 4a/4b/4c(生命周期双发、同接口单封装、N+1 过滤、getChildren 废弃)
-- 
-
-
-## v1.0.1285 (2026-09-02)
-
-### 新功能
-- 优化谷禾菌群报告详情页展示与饮食推荐
-
-### 其他
-- - 疾病风险内联展示并颜色区分(注意/低风险),修复详情页与风险页统计不一致
-- - 疾病风险页知识库入口改为 ? 图标放疾病名之前
-- - 菌群详情页修复分类 tab 过滤(中文 category 映射英文 key)
-- - 菌群风险分组(肥胖/抑郁/过敏等)改手风琴展示
-- - 饮食推荐页:去重标题、按推荐值分级背景色(<-30红/>30绿/每10一档)、去蛋白能量固定信息、增加食材搜索、复用知识库弹窗
-- - 成长页饮食推荐复用食材页,不传 reportId 取最新建议
-- 
-
-
-## v1.0.1284 (2026-09-02)
-
-### 重构
-- 清理遗留项(未用注入/静默catch/Set去重/UUID导入/参数校验/Javadoc)
+- 移除 ProductEdit.vue 中误粘贴的 AI 规划 Markdown 文本,修复构建失败
+- 健康报告列表删除按钮改为左滑出现,修复未注册组件导致按钮显示在行上方
 
 
 ## v1.0.1282 (2026-09-02)

+ 18 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -928,6 +928,24 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 > 注意:家庭券接口为 `POST /api/coupon/my` 与 `POST /api/coupon/list`(见 4.32),不存在 `/api/coupon/family/list` 路由。
 
+### 4.36 健康报告异步采集(`/api/health/report/*`, `/api/report-parser/*`)
+
+健康报告上传后进入**异步采集流程**:后端解析 PDF(先 `pdftotext` 提取文本内联,避免依赖代理工具执行),指纹判定报告类型 → 已知类型走专用采集脚本,未知类型交本地 opencode 服务(模型 `agnes-ai/agnes-2.5-flash`,在 session body 中显式指定)。前端提示「数据采集中」,完成后自动入库,列表页轮询刷新,无需用户等待。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/health/report/upload-only` | 生产小程序上传入口:上传 PDF → 落草稿 `health_report_drafts`(`parse_status=collecting`)→ **异步触发采集** → 返回草稿 ID(前端留意 `data.draftId` 后轮询) |
+| `POST /api/report-parser/upload` | 专用采集接口:同 async 流程,统一进异步采集 |
+| `POST /api/health/report/collect/status` | 查询单草稿采集状态,请求 `{ draftId }`,返回 `{ status, parseError }`(`collecting`/`completed`/`failed`) |
+| `POST /api/health/report/collecting-list` | 当前用户「采集中」草稿列表,返回 `List<Draft>`(前端轮询用) |
+
+**采集流程状态机:**
+- `collecting`:指纹判定 + 异步解析中
+- `completed`:解析成功 + 已自动入库(指标/菌群/疾病风险/食材)
+- `failed`:解析失败,`parse_error` 记录原因
+
+**专属采集脚本路径(在 `HealthReportDraftService`):** `getDraftById` / `markCollecting` / `updateParseMethod` / `markCollectCompleted` / `markCollectFailed` / `listCollectingByUser`。
+
 ---
 
 ## 六、待清理的废弃接口