浏览代码

feat(report): 已知类型解析失败退避重试 + 解析进度后台可见 + 食材回填

- 已知类型(fingerprint)解析失败不再静默回退 opencode,改为登记指数退避重试(30s→300s),由 ReportCollectRetryTask 定时驱动
- LangGraphReportParseService 新增 parseByScriptWithReason 保留失败原因,专用 RestTemplate 读超时放宽至 300s
- health_report_drafts 新增 parse_attempt/next_retry_at/parse_started_at/parse_finished_at(迁移364 + schema.sql)
- 新增 /api/admin/report-parse list|retry|backfill-food 及 reparseReportFoods(只覆盖 payload_json.foods)
- buildFoodsFromPayload 跳过空/无名称食材;saveFoodsForReport 先删后插保证幂等;入库前幂等守卫
Xiaogang Liao 23 小时之前
父节点
当前提交
f3fa60aab0

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

@@ -3020,6 +3020,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 商品组合推荐与采购任务迁移(363)
         createProcurementTaskTable();
+
+        // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode)
+        addDraftParseProgressColumns();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12400,4 +12403,24 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("创建procurement_tasks表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移364: health_report_drafts 添加解析进度/重试字段(INFORMATION_SCHEMA 检查后再 ALTER,幂等)
+    private void addDraftParseProgressColumns() {
+        if (!columnExists("health_report_drafts", "parse_attempt")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数'");
+            log.info("已添加 parse_attempt 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "next_retry_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次自动重试时间(指数退避,上限5分钟)'");
+            log.info("已添加 next_retry_at 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "parse_started_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间'");
+            log.info("已添加 parse_started_at 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "parse_finished_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)'");
+            log.info("已添加 parse_finished_at 列到 health_report_drafts 表");
+        }
+    }
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/RestTemplateConfig.java

@@ -17,4 +17,13 @@ public class RestTemplateConfig {
                 .setReadTimeout(Duration.ofSeconds(180))
                 .build();
     }
+
+    /** LangGraph 专用 RestTemplate:读超时放宽到 300s,避免大报告解析超时被误判失败 */
+    @Bean
+    public RestTemplate langGraphRestTemplate(RestTemplateBuilder builder) {
+        return builder
+                .setConnectTimeout(Duration.ofSeconds(30))
+                .setReadTimeout(Duration.ofSeconds(300))
+                .build();
+    }
 }

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

@@ -2319,6 +2319,10 @@ public class HealthReportController {
 
         List<Food> foods = new ArrayList<>();
         for (ParsedReportPayload.FoodItem pf : list) {
+            if (pf == null || pf.getName() == null || pf.getName().trim().isEmpty()) {
+                log.warn("跳过无效食材条目(元素为空或缺少名称)");
+                continue;
+            }
             Food food = new Food();
             food.setName(pf.getName());
             food.setCategory(pf.getCategory());

+ 131 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParseAdminController.java

@@ -0,0 +1,131 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 报告解析进度管理(管理员):草稿解析进度查询、手动重试、食材回填。
+ * 鉴权由 JwtInterceptor 对 /api/** 统一拦截。
+ */
+@RestController
+@RequestMapping("/api/admin/report-parse")
+public class ReportParseAdminController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParseAdminController.class);
+
+    @Resource
+    private HealthReportDraftMapper draftMapper;
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private ReportCollectService reportCollectService;
+
+    /** 草稿解析进度分页查询 */
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        String parseStatus = (String) params.get("parseStatus");
+        String reportType = (String) params.get("reportType");
+        String keyword = (String) params.get("keyword");
+
+        Page<HealthReportDraft> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<HealthReportDraft> wrapper = new LambdaQueryWrapper<>();
+        if (parseStatus != null && !parseStatus.isEmpty()) {
+            wrapper.eq(HealthReportDraft::getParseStatus, parseStatus);
+        }
+        if (reportType != null && !reportType.isEmpty()) {
+            wrapper.eq(HealthReportDraft::getReportType, reportType);
+        }
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(HealthReportDraft::getOriginalFilename, keyword)
+                    .or().like(HealthReportDraft::getUserId, keyword));
+        }
+        wrapper.orderByDesc(HealthReportDraft::getUpdatedAt);
+
+        Page<HealthReportDraft> result = draftMapper.selectPage(pageParam, wrapper);
+        List<Map<String, Object>> records = new ArrayList<>();
+        for (HealthReportDraft d : result.getRecords()) {
+            records.add(toRecordMap(d));
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("total", result.getTotal());
+        data.put("records", records);
+        return Result.success(data);
+    }
+
+    /** 手动重试:重置解析进度并重新提交异步采集 */
+    @PostMapping("/retry")
+    public Result<Map<String, Object>> retry(@RequestBody Map<String, Object> params) {
+        Long draftId = params.get("draftId") != null ? ((Number) params.get("draftId")).longValue() : null;
+        if (draftId == null) {
+            return Result.error("draftId 不能为空");
+        }
+        healthReportDraftService.resetForManualRetry(draftId);
+        reportCollectService.submitAsyncCollect(draftId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("draftId", draftId);
+        data.put("parseStatus", "collecting");
+        return Result.success(data);
+    }
+
+    /** 食材回填:重新解析报告并覆盖写入 foods(失败保留旧载荷) */
+    @PostMapping("/backfill-food")
+    public Result<Map<String, Object>> backfillFood(@RequestBody Map<String, Object> params) {
+        Long reportId = params.get("reportId") != null ? ((Number) params.get("reportId")).longValue() : null;
+        if (reportId == null) {
+            return Result.error("reportId 不能为空");
+        }
+        try {
+            int count = reportCollectService.reparseReportFoods(reportId);
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("reportId", reportId);
+            data.put("foodCount", count);
+            return Result.success(data);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("食材回填失败 reportId={}: {}", reportId, e.getMessage());
+            return Result.error(e.getMessage());
+        }
+    }
+
+    private Map<String, Object> toRecordMap(HealthReportDraft d) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("draftId", d.getId());
+        m.put("userId", d.getUserId());
+        m.put("reportType", d.getReportType());
+        m.put("originalFilename", d.getOriginalFilename());
+        m.put("fileHash", d.getFileHash());
+        m.put("parseStatus", d.getParseStatus());
+        m.put("parseMethod", d.getParseMethod());
+        m.put("parseError", d.getParseError());
+        m.put("parseAttempt", d.getParseAttempt());
+        m.put("nextRetryAt", d.getNextRetryAt());
+        m.put("parseStartedAt", d.getParseStartedAt());
+        m.put("parseFinishedAt", d.getParseFinishedAt());
+        m.put("collectValidation", d.getCollectValidation());
+        m.put("draftStatus", d.getStatus());
+        m.put("confirmedReportId", d.getConfirmedReportId());
+        m.put("createdAt", d.getCreatedAt());
+        m.put("updatedAt", d.getUpdatedAt());
+        return m;
+    }
+}

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

@@ -77,6 +77,18 @@ public class HealthReportDraft implements Serializable {
     /** 采集指标校验结果(JSON: typeId/expected/matched/pass/missing),由 ReportCollectService 采集后写入 */
     private String collectValidation;
 
+    /** 已知类型解析重试次数(指数退避,上限5分钟) */
+    private Integer parseAttempt;
+
+    /** 下次自动重试时间(known-type 解析失败后设置,成功/失败完成时清空) */
+    private Date nextRetryAt;
+
+    /** 最近一次解析开始时间 */
+    private Date parseStartedAt;
+
+    /** 最近一次解析结束时间(成功或失败) */
+    private Date parseFinishedAt;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 42 - 1
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportDraftMapper.java

@@ -3,7 +3,48 @@ package com.etotem.cfc.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.HealthReportDraft;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.Date;
+import java.util.List;
 
 @Mapper
 public interface HealthReportDraftMapper extends BaseMapper<HealthReportDraft> {
-}
+
+    /**
+     * 登记一次 known-type 解析失败重试:解析次数+1,写入错误原因,
+     * 并按指数退避(30s * 2^(attempt-1),上限300s)设置下次重试时间。
+     * 注意 SET 顺序:先按旧 parse_attempt 计算 next_retry_at,再自增。
+     *
+     * @return 受影响行数
+     */
+    @Update("UPDATE health_report_drafts " +
+            "SET next_retry_at = DATE_ADD(NOW(), INTERVAL LEAST(300, FLOOR(30 * POW(2, LEAST(COALESCE(parse_attempt, 0), 8)))) SECOND), " +
+            "parse_attempt = COALESCE(parse_attempt, 0) + 1, " +
+            "parse_error = #{error}, " +
+            "parse_status = 'collecting', " +
+            "updated_at = NOW() " +
+            "WHERE id = #{draftId}")
+    int recordRetry(@Param("draftId") Long draftId, @Param("error") String error);
+
+    /**
+     * 原子抢占一次重试:仅当 next_retry_at 为空或已到期时清空(归零待重试状态),返回影响行数。
+     * 抢到(rows==1)后由调度任务重新提交异步采集。
+     */
+    @Update("UPDATE health_report_drafts SET next_retry_at = NULL " +
+            "WHERE id = #{draftId} AND (next_retry_at IS NULL OR next_retry_at <= #{now})")
+    int claimRetry(@Param("draftId") Long draftId, @Param("now") Date now);
+
+    /**
+     * 查询待重试草稿:状态 pending + 采集中 + (已到 next_retry_at,或从未有过 next_retry_at 且
+     * 最近一次解析开始超过10分钟——用于兜底卡死/丢失的采集任务),按 updated_at 升序取前 N 条。
+     */
+    @Select("SELECT * FROM health_report_drafts " +
+            "WHERE status = 'pending' AND parse_status = 'collecting' " +
+            "AND ((next_retry_at IS NOT NULL AND next_retry_at <= #{now}) " +
+            "OR (next_retry_at IS NULL AND (parse_started_at IS NULL OR parse_started_at < DATE_SUB(#{now}, INTERVAL 10 MINUTE)))) " +
+            "ORDER BY updated_at ASC LIMIT #{limit}")
+    List<HealthReportDraft> listPendingRetry(@Param("now") Date now, @Param("limit") int limit);
+}

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

@@ -172,6 +172,8 @@ public class FoodService {
     }
 
     public void saveFoodsForReport(Long reportId, List<Food> foods) {
+        // 幂等入库:先删除该报告已有的食材推荐,再写入新数据(重新解析/回填时避免重复行)
+        foodMapper.delete(new LambdaQueryWrapper<Food>().eq(Food::getReportId, reportId));
         if (foods == null || foods.isEmpty()) return;
         int sortOrder = 0;
         for (Food food : foods) {

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

@@ -226,6 +226,7 @@ public class HealthReportDraftService {
         uw.eq(HealthReportDraft::getId, draftId)
           .set(HealthReportDraft::getParseStatus, "failed")
           .set(HealthReportDraft::getParseError, safe)
+          .set(HealthReportDraft::getParseFinishedAt, new Date())
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
@@ -240,6 +241,7 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getParseError, null)
           .set(HealthReportDraft::getStatus, "confirmed")
           .set(HealthReportDraft::getConfirmedReportId, reportId)
+          .set(HealthReportDraft::getParseFinishedAt, new Date())
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
@@ -254,4 +256,54 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
+
+    /**
+     * 解析开始前声明占用:置 parse_status=collecting、parse_started_at=now、清空 next_retry_at 与上次错误。
+     */
+    public void markParseStarted(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, "collecting")
+          .set(HealthReportDraft::getParseStartedAt, new Date())
+          .set(HealthReportDraft::getNextRetryAt, null)
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 登记一次 known-type 解析失败重试(不置 failed,保持 collecting,交由定时驱动器退避重试)。
+     */
+    public void recordRetry(Long draftId, String error) {
+        String safe = error != null && error.length() > 2000 ? error.substring(0, 2000) : error;
+        draftMapper.recordRetry(draftId, safe);
+    }
+
+    /**
+     * 查询待重试草稿(状态 pending + 采集中 + 重试时间已到或采集可能卡死),按 updated_at 升序取前 limit 条。
+     */
+    public java.util.List<HealthReportDraft> listPendingRetry(Date now, int limit) {
+        return draftMapper.listPendingRetry(now, limit);
+    }
+
+    /**
+     * 原子抢占一次重试(仅 next_retry_at 为空或已到期时成功),抢到后调用方重新提交异步采集。
+     */
+    public boolean claimRetry(Long draftId, Date now) {
+        return draftMapper.claimRetry(draftId, now) == 1;
+    }
+
+    /**
+     * 人工重试重置:清零重试次数与错误,回到 collecting 待采集状态。
+     */
+    public void resetForManualRetry(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseAttempt, 0)
+          .set(HealthReportDraft::getNextRetryAt, null)
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getParseStatus, "collecting")
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1201,6 +1201,15 @@ public class HealthReportService {
         return healthReportMapper.selectById(reportId);
     }
 
+    /**
+     * 仅更新报告的 payload_json 列(用于食材重新解析回填,只覆盖 foods 键,不影响其他字段)。
+     * 使用 JdbcTemplate 直改单列,避免 MyBatis-Plus NOT_NULL 策略下其他 null 字段被误覆盖。
+     */
+    public void updatePayloadJson(Long reportId, String payloadJson) {
+        jdbcTemplate.update("UPDATE health_reports SET payload_json = ?, updated_at = NOW() WHERE id = ?",
+                payloadJson, reportId);
+    }
+
     public void bindReportToMember(Long reportId, Long subjectId) {
         // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
         String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";

+ 86 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java

@@ -36,7 +36,8 @@ public class LangGraphReportParseService {
     public static final String DETECTED_GENERIC = "generic";
 
     @Resource
-    private RestTemplate restTemplate;
+    @org.springframework.beans.factory.annotation.Qualifier("langGraphRestTemplate")
+    private RestTemplate langGraphRestTemplate;
 
     @Resource
     private ObjectMapper objectMapper;
@@ -44,21 +45,69 @@ public class LangGraphReportParseService {
     @Value("${langgraph.base-url:http://localhost:9000}")
     private String langgraphBaseUrl;
 
+    /**
+     * 解析结果 + 失败原因(known-type 分支用 parse_error 落库,避免失败原因被吞掉)。
+     */
+    public static class ParseOutcome {
+        private final ParsedReportPayload.Payload payload;
+        private final String error;
+
+        private ParseOutcome(ParsedReportPayload.Payload payload, String error) {
+            this.payload = payload;
+            this.error = error;
+        }
+
+        public static ParseOutcome success(ParsedReportPayload.Payload payload) {
+            return new ParseOutcome(payload, null);
+        }
+
+        public static ParseOutcome failure(String error) {
+            return new ParseOutcome(null, error);
+        }
+
+        public ParsedReportPayload.Payload getPayload() { return payload; }
+        public String getError() { return error; }
+    }
+
+    /** HTTP 调用结果:data 或 error(二选一) */
+    private static class HttpResult {
+        final Map<String, Object> data;
+        final String error;
+
+        HttpResult(Map<String, Object> data, String error) {
+            this.data = data;
+            this.error = error;
+        }
+    }
+
     /**
      * 指纹成功路径:调 LangGraph 脚本(/report/parse,算法 + LLM 兜底)。
      * 转换后无有效指标数据时返回 null。
      */
     public ParsedReportPayload.Payload parseByScript(String pdfPath) {
-        return parseByScript(pdfPath, null);
+        ParseOutcome outcome = parseByScriptWithReason(pdfPath, null);
+        return outcome != null ? outcome.getPayload() : null;
     }
 
     public ParsedReportPayload.Payload parseByScript(String pdfPath, List<String> indicators) {
-        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath, indicators);
-        if (data == null) {
-            return null;
+        ParseOutcome outcome = parseByScriptWithReason(pdfPath, indicators);
+        return outcome != null ? outcome.getPayload() : null;
+    }
+
+    /**
+     * 带失败原因版本的脚本解析:失败时返回 null payload 且 error 含具体原因
+     * (如 "Read timed out"、http=500、空 data 等),供调用方登记重试与落库 parse_error。
+     */
+    public ParseOutcome parseByScriptWithReason(String pdfPath, List<String> indicators) {
+        HttpResult r = postParseWithReason("/api/v1/report/parse", pdfPath, indicators);
+        if (r == null || r.data == null) {
+            return ParseOutcome.failure(r != null && r.error != null ? r.error : "解析结果为空或无效");
         }
-        ParsedReportPayload.Payload payload = buildPayloadFromLgData(data);
-        return isValid(payload) ? payload : null;
+        ParsedReportPayload.Payload payload = buildPayloadFromLgData(r.data);
+        if (!isValid(payload)) {
+            return ParseOutcome.failure("LangGraph 解析结果无有效指标数据");
+        }
+        return ParseOutcome.success(payload);
     }
 
     /**
@@ -128,29 +177,50 @@ public class LangGraphReportParseService {
         return postParse(path, body);
     }
 
-    @SuppressWarnings("unchecked")
     private Map<String, Object> postParse(String path, Map<String, Object> body) {
+        HttpResult r = postParseWithReason(path, body);
+        return r != null ? r.data : null;
+    }
+
+    /** 带失败原因版本的 LangGraph 调用(known-type 分支用于把失败原因写入 parse_error) */
+    private HttpResult postParseWithReason(String path, String pdfPath, List<String> indicators) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("file_path", pdfPath);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
+        return postParseWithReason(path, body);
+    }
+
+    @SuppressWarnings("unchecked")
+    private HttpResult postParseWithReason(String path, Map<String, Object> body) {
         try {
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
-            ResponseEntity<Map> resp = restTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
+            ResponseEntity<Map> resp = langGraphRestTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
             Map<String, Object> respBody = resp.getBody();
-            if (respBody == null || !Integer.valueOf(200).equals(respBody.get("code"))) {
+            if (respBody == null) {
+                log.warn("LangGraph 端点 {} 返回空响应", path);
+                return new HttpResult(null, path + " 返回空响应");
+            }
+            if (!Integer.valueOf(200).equals(respBody.get("code"))) {
+                String detail = "http=" + String.valueOf(respBody.get("code"))
+                        + " message=" + String.valueOf(respBody.get("message"));
                 log.warn("LangGraph 端点 {} 返回异常: code={}, message={}",
-                        path, respBody != null ? respBody.get("code") : null,
-                        respBody != null ? respBody.get("message") : null);
-                return null;
+                        path, respBody.get("code"), respBody.get("message"));
+                return new HttpResult(null, path + " 返回异常: " + detail);
             }
             Object data = respBody.get("data");
             if (!(data instanceof Map) || ((Map<String, Object>) data).isEmpty()) {
                 log.warn("LangGraph 端点 {} 返回空 data", path);
-                return null;
+                return new HttpResult(null, path + " 返回空 data(无有效解析结果)");
             }
-            return (Map<String, Object>) data;
+            return new HttpResult((Map<String, Object>) data, null);
         } catch (Exception e) {
             log.warn("LangGraph 端点 {} 调用失败: {}", path, e.getMessage());
-            return null;
+            String reason = "LangGraph 调用异常(" + e.getClass().getSimpleName() + "): " + e.getMessage();
+            return new HttpResult(null, reason);
         }
     }
 

+ 76 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.*;
@@ -152,7 +153,7 @@ public class ReportCollectService {
             return;
         }
         try {
-            healthReportDraftService.markCollecting(draftId, null);
+            healthReportDraftService.markParseStarted(draftId);
             String pdfPath = resolvePdfPath(draft);
             if (pdfPath == null) {
                 healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
@@ -192,13 +193,18 @@ public class ReportCollectService {
             String method;
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
                 // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
+                // 已知类型解析失败不再回退 opencode,改为登记退避重试(parse_error 记录失败原因),
+                // 由 ReportCollectRetryTask 定时驱动重试,避免静默降级导致 foods 等数据缺失。
                 method = METHOD_LANGGRAPH_SCRIPT;
-                payload = langGraphReportParseService.parseByScript(pdfPath, expectedIndicators);
-                // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
+                LangGraphReportParseService.ParseOutcome outcome =
+                        langGraphReportParseService.parseByScriptWithReason(pdfPath, expectedIndicators);
+                payload = outcome != null ? outcome.getPayload() : null;
                 if (payload == null) {
-                    log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
-                    method = METHOD_OPENCODE;
-                    payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+                    String reason = outcome != null && outcome.getError() != null
+                            ? outcome.getError() : "解析结果为空或无效";
+                    log.warn("已知类型 LangGraph 脚本解析失败,登记退避重试 typeId={}, reason={}", typeId, reason);
+                    healthReportDraftService.recordRetry(draftId, reason);
+                    return;
                 }
             } else {
                 // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
@@ -226,6 +232,15 @@ public class ReportCollectService {
             healthReportDraftService.updateParseMethod(draftId, method);
             healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
 
+            // 幂等守卫:解析期间若草稿已被确认入库或已完成(并发/外部触发),跳过本次入库
+            HealthReportDraft fresh = healthReportDraftService.getDraftById(draftId);
+            if (fresh != null && (fresh.getConfirmedReportId() != null
+                    || STATUS_COMPLETED.equals(fresh.getParseStatus()))) {
+                log.warn("草稿解析期间已被处理,跳过重复入库 draftId={}, confirmedReportId={}, parseStatus={}",
+                        draftId, fresh.getConfirmedReportId(), fresh.getParseStatus());
+                return;
+            }
+
             insertReport(draft, payload);
 
             log.info("异步采集完成 draftId={}, method={}", draftId, method);
@@ -811,6 +826,10 @@ public class ReportCollectService {
         if (list == null || list.isEmpty()) return new ArrayList<>();
         List<Food> foods = new ArrayList<>();
         for (ParsedReportPayload.FoodItem pf : list) {
+            if (pf == null || pf.getName() == null || pf.getName().trim().isEmpty()) {
+                log.warn("跳过无效食材条目(元素为空或缺少名称)");
+                continue;
+            }
             Food food = new Food();
             food.setName(pf.getName());
             food.setCategory(pf.getCategory());
@@ -828,6 +847,57 @@ public class ReportCollectService {
         return foods;
     }
 
+    /**
+     * 重新解析指定健康报告并回填食材推荐。
+     * 先删除旧 foods 再插入(幂等),解析成功后仅覆盖 payload_json 的 foods 键(其他键不变),
+     * 解析失败则保留旧载荷并抛 IllegalStateException。
+     *
+     * @return 本次落库的食材条数
+     */
+    public int reparseReportFoods(Long reportId) {
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            throw new IllegalArgumentException("报告不存在: " + reportId);
+        }
+        String pdfPath = storageService.getFilePath(report.getFileUrl());
+        File f = new File(pdfPath);
+        if (!f.exists()) {
+            throw new IllegalStateException("报告文件不存在: " + pdfPath);
+        }
+        LangGraphReportParseService.ParseOutcome outcome =
+                langGraphReportParseService.parseByScriptWithReason(pdfPath, null);
+        ParsedReportPayload.Payload payload = outcome != null ? outcome.getPayload() : null;
+        if (payload == null) {
+            String reason = outcome != null && outcome.getError() != null ? outcome.getError() : "解析结果为空";
+            throw new IllegalStateException("重新解析失败: " + reason);
+        }
+        List<Food> foods = buildFoodsFromPayload(payload);
+        foodService.saveFoodsForReport(reportId, foods);
+
+        // 仅合并 foods 键到原 payload_json,不覆盖其他键;失败时保留旧载荷
+        try {
+            JsonNode root = objectMapper.readTree(report.getPayloadJson());
+            if (root != null && root.isObject()) {
+                ((ObjectNode) root).set("foods", objectMapper.valueToTree(payload.getFoods()));
+                healthReportService.updatePayloadJson(reportId, objectMapper.writeValueAsString(root));
+            }
+        } catch (Exception e) {
+            log.warn("合并 foods 到 payload_json 失败 reportId={}: {}", reportId, e.getMessage());
+        }
+
+        // 刷新食材推荐指数(优先以报告主体成员为准,其次上传用户)
+        try {
+            Long recalcUserId = report.getSubjectId() != null ? report.getSubjectId() : report.getUserId();
+            if (recalcUserId != null) {
+                foodRecommendService.recalculateForUser(recalcUserId);
+            }
+        } catch (Exception e) {
+            log.warn("刷新食材推荐指数失败 reportId={}: {}", reportId, e.getMessage());
+        }
+
+        return foods.size();
+    }
+
     // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
 
     private String opencodeCreateSession() {

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/ReportCollectRetryTask.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 已知类型报告解析失败重试驱动器:
+ * 定时捞取"采集中但已到退避重试时间(或可能卡死)"的草稿,抢占后重新提交异步采集。
+ */
+@Slf4j
+@Component
+public class ReportCollectRetryTask {
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private ReportCollectService reportCollectService;
+
+    @Scheduled(fixedDelayString = "${report.collect.retry-interval-ms:60000}",
+            initialDelayString = "${report.collect.retry-initial-delay-ms:60000}")
+    public void redrivePendingRetries() {
+        try {
+            Date now = new Date();
+            List<HealthReportDraft> drafts = healthReportDraftService.listPendingRetry(now, 20);
+            if (drafts == null || drafts.isEmpty()) {
+                return;
+            }
+            log.info("定时重试驱动器: 发现 {} 条待重试草稿", drafts.size());
+            for (HealthReportDraft draft : drafts) {
+                try {
+                    if (healthReportDraftService.claimRetry(draft.getId(), now)) {
+                        reportCollectService.submitAsyncCollect(draft.getId());
+                        log.info("已抢占并重新提交异步采集 draftId={}", draft.getId());
+                    }
+                } catch (Exception e) {
+                    log.warn("重试草稿提交失败 draftId={}: {}", draft.getId(), e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            log.error("定时重试驱动器执行异常", e);
+        }
+    }
+}

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

@@ -1890,6 +1890,10 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
     remark VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)',
     collect_validation TEXT DEFAULT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)',
+    parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数(指数退避,上限5分钟)',
+    next_retry_at DATETIME NULL COMMENT '下次自动重试时间(known-type 解析失败后设置)',
+    parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间',
+    parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),