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

merge: feature/proxy-fix-b3-robust (T29-B3 报告代传健壮性修复)

- B3-1 proxyConfirm 恒写 aiAnalysisStatus/aiAnalysisMessage,前端 toast 化
- B3-2 草稿过期降级:isDraftExpired -> reparseExpiredDraft 走 /report/proxy-parse
- B3-3 markParseMethod 写 parseFallback/parseMethod,本地兜底提示
- B3-4 /report/list 支持 page>0 分页,page 不传仍返回 List(小程序兼容)
- B3-5 新增 docs/deployment/report-parse-langgraph-env.md
- B3-6 updateNote -> updateRemark 命名澄清

独立核验:PASS,无 P0(lan-189 ses_e6f7b8810ffeBUj7DJz4YF5wHf)
核验报告:docs/superpowers/plans/verify/verify-t29-b3-2026-10-31.md
Agnes Agent 1 неделя назад
Родитель
Сommit
7a5fa61efc

+ 81 - 19
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -327,10 +327,12 @@ public class HealthReportController {
      */
     @Operation(summary = "获取健康报告列表")
     @PostMapping("/report/list")
-    public Result<List<HealthReport>> listReports(
+    public Result<Object> listReports(
             @RequestBody Map<String, Object> params,
             @RequestAttribute("userId") Long userId,
             @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        // B3-4:分页支持——page 不传时行为与现一致(返回 List,兼容小程序端);page 传时返回分页结构。
+        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 0;
         // 返回条数:显式传 size 则截取前 size 条;不传则返回全部分页列表
         int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 0;
         Long memberId = ParamUtils.getLong(params.get("memberId"));
@@ -369,6 +371,27 @@ public class HealthReportController {
         // 合并 DAN 报告(已确认入库的)到列表,保证报告列表页能看到 DAN 报告
         List<HealthReport> merged = mergeDanReports(reports, memberId, familyId, visibleForDan, userId);
 
+        if (page > 0) {
+            // B3-4:分页返回结构(对齐项目分页惯例,与 /report/admin-list 的 records/total 口径一致);
+            //        teacher 端代传历史页已按 res.data.records / res.data.total 读取。
+            int pageSize = size > 0 ? size : 20;
+            int total = merged.size();
+            int from = (page - 1) * pageSize;
+            if (from > total) {
+                from = total;
+            }
+            int to = Math.min(from + pageSize, total);
+            List<HealthReport> records = from < total
+                    ? new ArrayList<>(merged.subList(from, to)) : new ArrayList<>();
+            healthReportService.fillSubjectAndProxyNames(records);
+            Map<String, Object> pageResult = new java.util.HashMap<>();
+            pageResult.put("records", records);
+            pageResult.put("total", total);
+            pageResult.put("current", page);
+            pageResult.put("size", pageSize);
+            pageResult.put("pages", (int) Math.ceil((double) total / pageSize));
+            return Result.success(pageResult);
+        }
         if (size > 0) {
             // B1-6:列表补展示字段(报告对象姓名 / 代上传人姓名),实体上的 @TableField(exist=false) 字段
             List<HealthReport> limited = limitLatest(merged, size);
@@ -2725,9 +2748,17 @@ public class HealthReportController {
      *
      * <p>T29-B1-1:重构为与「两阶段入库」一致的模式——先建草稿拿到 draftId,再解析并回写草稿载荷,
      * 前端「确认入库」才能拿到 draftId(原先透传 LangGraph 原始返回,恒无 draftId → 前端卡死)。</p>
-     * <p>链路:storeFile → createDraft → updateNote(备注) → getFilePath → LangGraph 解析
+     * <p>链路:storeFile → createDraft → updateRemark(备注) → getFilePath → LangGraph 解析
      * (失败回退本地 Java 解析)→ 返回 draftId/payload/成员匹配/提取字段。</p>
      *
+     * <p><b>B3-2 草稿过期降级:</b>入参支持 <code>file</code>(新上传,multipart)或
+     * <code>fileUrl</code>(草稿过期后基于原文件重新解析,无需重新上传 PDF)。二者至少给一个,
+     * 复用本接口同一条「建草稿 → 解析」链路,不另建新接口。</p>
+     *
+     * <p><b>B3-3 本地兜底提示:</b>LangGraph 不可用而走本地 Java 解析时,返回体带
+     * <code>parseFallback=true</code> / <code>parseMethod="local"</code>,供前端提示
+     * 「解析服务暂不可用,已用本地解析」。</p>
+     *
      * <p><b>T29-B2-6 权限编排(三层校验,各层失败返回码):</b>
      * <ol>
      *   <li>① 角色校验 {@link #isProxyUploader}:非 admin/teacher/approved 营养师/approved 规划师
@@ -2743,7 +2774,8 @@ public class HealthReportController {
     @Operation(summary = "代上传报告(预览)")
     @PostMapping("/report/proxy-parse")
     public Result<Map<String, Object>> proxyParsePreview(
-            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "file", required = false) MultipartFile file,
+            @RequestParam(value = "fileUrl", required = false) String fileUrl,
             @RequestParam(value = "familyId", required = false) Long familyId,
             @RequestParam(value = "subjectId", required = false) Long subjectId,
             @RequestParam(value = "reportType", defaultValue = "auto") String reportType,
@@ -2753,8 +2785,11 @@ public class HealthReportController {
             @RequestAttribute("roles") List<String> roles,
             HttpServletRequest request) {
 
-        if (file.isEmpty()) {
-            return Result.error("文件不能为空");
+        // B3-2:两种入参二选一——file(新上传,multipart)或 fileUrl(草稿过期后基于原文件重新解析)
+        boolean hasFile = file != null && !file.isEmpty();
+        boolean hasFileUrl = fileUrl != null && !fileUrl.trim().isEmpty();
+        if (!hasFile && !hasFileUrl) {
+            return Result.error("请选择PDF文件或提供fileUrl");
         }
 
         User user = userMapper.selectById(userId);
@@ -2772,17 +2807,22 @@ public class HealthReportController {
             return Result.error("无权为该家庭代传报告");
         }
 
-        String contentType = file.getContentType();
-        String originalFilename = file.getOriginalFilename();
-        String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
-        if (!lcName.endsWith(".pdf")
-                && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
-            return Result.error("仅支持PDF文件");
+        // 新上传路径才校验 PDF 类型(fileUrl 重新解析路径的文件已在首次上传时校验过)
+        if (hasFile) {
+            String contentType = file.getContentType();
+            String originalFilename = file.getOriginalFilename();
+            String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
+            if (!lcName.endsWith(".pdf")
+                    && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
+                return Result.error("仅支持PDF文件");
+            }
         }
 
         try {
-            // ① 存文件(本地磁盘 / 京东云 OSS)
-            String fileUrl = storageService.storeFile(file, "health-reports");
+            // ① 确定文件访问地址:新上传 → storeFile 存本地/OSS;fileUrl → 复用既有文件
+            boolean reparse = !hasFile && hasFileUrl;
+            String resolvedFileUrl = hasFile ? storageService.storeFile(file, "health-reports") : fileUrl.trim();
+            String originalFilename = hasFile ? file.getOriginalFilename() : null;
 
             // ② 先建草稿:保证预览必然有 draftId 供 /report/proxy-confirm 入库
             //    reportType 传 auto 时落 gut_flora(与非代传链路一致)
@@ -2792,15 +2832,15 @@ public class HealthReportController {
                     ? "gut_flora" : reportType;
             String emptyPayloadJson = objectMapper.writeValueAsString(new ParsedReportPayload.Payload());
             HealthReportDraft draft = healthReportDraftService.createDraft(
-                    userId, familyId, draftReportType, fileUrl, originalFilename, emptyPayloadJson);
+                    userId, familyId, draftReportType, resolvedFileUrl, originalFilename, emptyPayloadJson);
 
-            // ③ 备注写入草稿 remark(updateNote 实际写 remark 列),确认入库时同步到 proxy_upload_note
+            // ③ 备注写入草稿 remark(B3-6:updateRemark 实际写 remark 列),确认入库时同步到 proxy_upload_note
             if (note != null && !note.isEmpty()) {
-                healthReportDraftService.updateNote(draft.getId(), note);
+                healthReportDraftService.updateRemark(draft.getId(), note);
             }
 
             // ④ LangGraph 需要本地路径:经 getFilePath 下载/定位到共享卷
-            String absolutePath = storageService.getFilePath(fileUrl);
+            String absolutePath = storageService.getFilePath(resolvedFileUrl);
 
             // ⑤ 解析(LangGraph 优先,失败回退本地 Java 解析)
             Map<String, Object> data = parseProxyDraftFile(draft, absolutePath, userId, familyId, subjectId);
@@ -2815,9 +2855,10 @@ public class HealthReportController {
             }
 
             // ⑥ 返回体补代传相关字段(前端既有字段契约)
-            data.put("fileUrl", fileUrl);
+            data.put("fileUrl", resolvedFileUrl);
             data.put("proxyUploaderId", userId);
             data.put("proxyUploadNote", note);
+            data.put("reparse", reparse);
             return Result.success(data);
 
         } catch (Exception e) {
@@ -2845,6 +2886,7 @@ public class HealthReportController {
             if (lgData != null) {
                 Map<String, Object> data = buildParseResultFromLg(lgData, draft, familyId).getData();
                 putExtractedReportMeta(data, lgData);
+                markParseMethod(data, false, "langgraph");
                 return data;
             }
             log.warn("代传解析:LangGraph 无有效返回,回退本地 Java 解析 draftId={}", draft.getId());
@@ -2853,13 +2895,33 @@ public class HealthReportController {
                     draft.getId(), e.getMessage());
         }
         try {
-            return parseLocalFallback(absolutePath, draft, familyId, userId).getData();
+            Map<String, Object> data = parseLocalFallback(absolutePath, draft, familyId, userId).getData();
+            if (data != null) {
+                // B3-3:本地兜底成功 → 前端提示「解析服务暂不可用,已用本地解析」
+                markParseMethod(data, true, "local");
+            }
+            return data;
         } catch (Exception e) {
             log.error("代传解析:本地 Java 解析失败 draftId={}", draft.getId(), e);
             return null;
         }
     }
 
+    /**
+     * B3-3:标注本次解析走 LangGraph 还是本地 Java 兜底,供前端差异化提示。
+     *
+     * @param data      预览结果体(非 null)
+     * @param fallback  是否走了本地 Java 兜底
+     * @param method    解析方式标识(langgraph / local)
+     */
+    private void markParseMethod(Map<String, Object> data, boolean fallback, String method) {
+        if (data == null) {
+            return;
+        }
+        data.put("parseFallback", fallback);
+        data.put("parseMethod", method);
+    }
+
     /**
      * 补齐 LangGraph 预览结果的 extractedReportNumber / extractedReportDate。
      * buildParseResultFromLg 只回填 extractedName/Gender/Age,而前端预览页三行

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -148,10 +148,14 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getUpdatedAt, now);
         draftMapper.update(null, uw);
     }
-    public void updateNote(Long draftId, String note) {
+    /**
+     * B3-6:更新草稿备注(写入 health_report_drafts.remark 列,草稿表无 note 列)。
+     * 原方法名 updateNote 与实际写入列不符,已更名为 updateRemark。
+     */
+    public void updateRemark(Long draftId, String remark) {
         LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
         uw.eq(HealthReportDraft::getId, draftId)
-          .set(HealthReportDraft::getRemark, note)
+          .set(HealthReportDraft::getRemark, remark)
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }

+ 151 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerListPaginationTest.java

@@ -0,0 +1,151 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.service.DanReportUploadService;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.when;
+
+/**
+ * T29-B3-4:/report/list 分页修复。
+ *
+ * 兼容性约束:page 不传时返回 List(小程序端既有调用依赖);
+ * page 传时返回分页结构 {records, total, current, size, pages}(teacher 代传历史页依赖)。
+ */
+public class HealthReportControllerListPaginationTest {
+
+    @Mock
+    private HealthReportService healthReportService;
+    @Mock
+    private ReportMemberPermissionService reportMemberPermissionService;
+    @Mock
+    private DanReportUploadService danReportUploadService;
+
+    private HealthReportController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new HealthReportController();
+        ReflectionTestUtils.setField(controller, "healthReportService", healthReportService);
+        ReflectionTestUtils.setField(controller, "reportMemberPermissionService", reportMemberPermissionService);
+        ReflectionTestUtils.setField(controller, "danReportUploadService", danReportUploadService);
+    }
+
+    private List<HealthReport> buildReports(int count) {
+        List<HealthReport> list = new ArrayList<>();
+        for (int i = 1; i <= count; i++) {
+            HealthReport r = new HealthReport();
+            r.setId((long) i);
+            r.setReportType("gut_flora");
+            r.setFamilyId(10L);
+            r.setSubjectId((long) i);
+            r.setUserId(1L);
+            r.setCreatedAt(new Date());
+            r.setStatus("active");
+            list.add(r);
+        }
+        return list;
+    }
+
+    private Map<String, Object> params(Object page, Object size) {
+        Map<String, Object> p = new HashMap<>();
+        if (page != null) {
+            p.put("page", page);
+        }
+        if (size != null) {
+            p.put("size", size);
+        }
+        return p;
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testPageProvidedReturnsPaginatedStructure() {
+        when(reportMemberPermissionService.visibleIdentityIds(eq(1L), eq(10L))).thenReturn(null);
+        when(healthReportService.getFamilyReports(10L)).thenReturn(buildReports(25));
+        when(danReportUploadService.listConfirmedForReportList(isNull(), eq(10L), isNull()))
+                .thenReturn(new ArrayList<>());
+        doNothing().when(healthReportService).fillSubjectAndProxyNames(any());
+
+        Result<Object> result = controller.listReports(params(1, 20), 1L, 10L);
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData() instanceof Map, "page 传时返回分页结构");
+        Map<String, Object> pageResult = (Map<String, Object>) result.getData();
+        List<HealthReport> records = (List<HealthReport>) pageResult.get("records");
+        assertEquals(20, records.size());
+        assertEquals(25, pageResult.get("total"));
+        assertEquals(1, pageResult.get("current"));
+        assertEquals(20, pageResult.get("size"));
+        assertEquals(2, pageResult.get("pages"));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testPageBeyondTotalReturnsEmptyRecords() {
+        when(reportMemberPermissionService.visibleIdentityIds(eq(1L), eq(10L))).thenReturn(null);
+        when(healthReportService.getFamilyReports(10L)).thenReturn(buildReports(25));
+        when(danReportUploadService.listConfirmedForReportList(isNull(), eq(10L), isNull()))
+                .thenReturn(new ArrayList<>());
+
+        Result<Object> result = controller.listReports(params(99, 20), 1L, 10L);
+        Map<String, Object> pageResult = (Map<String, Object>) result.getData();
+        assertEquals(0, ((List<?>) pageResult.get("records")).size());
+        assertEquals(25, pageResult.get("total"));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testNoPageReturnsListCompatWithMiniProgram() {
+        when(reportMemberPermissionService.visibleIdentityIds(eq(1L), eq(10L))).thenReturn(null);
+        when(healthReportService.getFamilyReports(10L)).thenReturn(buildReports(25));
+        when(danReportUploadService.listConfirmedForReportList(isNull(), eq(10L), isNull()))
+                .thenReturn(new ArrayList<>());
+
+        // 不传 page 只传 size:返回 List(截取前 N 条),行为与现一致
+        Result<Object> result = controller.listReports(params(null, 3), 1L, 10L);
+        assertTrue(result.getData() instanceof List, "page 不传时返回 List");
+        assertEquals(3, ((List<?>) result.getData()).size());
+
+        // 都不传:返回完整 List
+        result = controller.listReports(params(null, null), 1L, 10L);
+        assertTrue(result.getData() instanceof List);
+        assertEquals(25, ((List<?>) result.getData()).size());
+    }
+
+    @Test
+    public void testMarkParseMethodFlags() throws Exception {
+        // B3-3:markParseMethod 正确标注本地兜底标记(私有方法,反射调用)
+        Map<String, Object> data = new HashMap<>();
+        java.lang.reflect.Method method =
+                HealthReportController.class.getDeclaredMethod("markParseMethod", Map.class, boolean.class, String.class);
+        method.setAccessible(true);
+        method.invoke(controller, data, true, "local");
+        assertEquals(Boolean.TRUE, data.get("parseFallback"));
+        assertEquals("local", data.get("parseMethod"));
+
+        Map<String, Object> data2 = new HashMap<>();
+        method.invoke(controller, data2, false, "langgraph");
+        assertEquals(Boolean.FALSE, data2.get("parseFallback"));
+        assertEquals("langgraph", data2.get("parseMethod"));
+    }
+}

+ 15 - 0
cfc-web/src/api/admin.js

@@ -2129,6 +2129,21 @@ export function proxyParseReport(data) {
   })
 }
 
+// B3-2:草稿过期降级——基于原文件 fileUrl 重新建草稿并解析(复用 /report/proxy-parse,不重新上传 PDF)
+export function proxyReparseReport(data) {
+  const formData = new FormData()
+  formData.append('fileUrl', data.fileUrl)
+  formData.append('familyId', data.familyId)
+  if (data.subjectId) formData.append('subjectId', data.subjectId)
+  if (data.note) formData.append('note', data.note)
+  return request({
+    url: '/api/health/report/proxy-parse',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
 export function proxyConfirmReport(data) {
   return request({
     url: '/api/health/report/proxy-confirm',

+ 67 - 2
cfc-web/src/views/admin/ProxyReportUpload.vue

@@ -129,7 +129,7 @@
 <script>
 import ListStateMixin from '@/mixins/ListStateMixin'
 import { getFamilyList, getFamilyMemberList } from '@/api/admin'
-import { proxyParseReport, proxyConfirmReport, adminListHealthReports, getHealthReportDetail } from '@/api/admin'
+import { proxyParseReport, proxyReparseReport, proxyConfirmReport, adminListHealthReports, getHealthReportDetail } from '@/api/admin'
 
 export default {
   name: 'ProxyReportUpload',
@@ -193,6 +193,7 @@ export default {
         if (res.code === 200) {
           this.previewResult = res.data
           this.analysisResult = null
+          this.notifyParseFallback(res.data)
         } else {
           this.$message.error(res.message || '解析失败')
         }
@@ -213,8 +214,11 @@ export default {
         if (res.code === 200) {
           this.$message.success('报告入库成功')
           this.previewResult = null
-          this.analysisResult = res.data && res.data.aiAnalysis ? res.data.aiAnalysis : null
+          this.applyAiAnalysisResult(res.data)
           this.loadProxyReports()
+        } else if (this.isDraftExpired(res.message)) {
+          // B3-2:草稿过期降级——提示重新上传,支持基于 fileUrl 重新解析(复用原文件)
+          this.reparseExpiredDraft()
         } else {
           this.$message.error(res.message || '确认失败')
         }
@@ -224,6 +228,67 @@ export default {
         this.confirming = false
       }
     },
+    // B3-1:按 B1-5 返回的 aiAnalysisStatus/aiAnalysisMessage 提示「AI 解读未完成:原因」而非静默
+    applyAiAnalysisResult(data) {
+      var aiStatus = data && data.aiAnalysisStatus
+      // 有解读结果即展示;状态缺失时按成功处理(兼容未带 status 的旧返回)
+      if ((aiStatus === 'success' || !aiStatus) && data && data.aiAnalysis) {
+        this.analysisResult = data.aiAnalysis
+        return
+      }
+      this.analysisResult = null
+      var reason = (data && data.aiAnalysisMessage) || (data && data.aiAnalysisStatus) || ''
+      this.$message.warning('AI解读未完成:' + (reason || '未知原因'))
+    },
+    // B3-3:LangGraph 不可用走本地解析时的提示文案
+    notifyParseFallback(data) {
+      if (data && data.parseFallback) {
+        this.$message.warning('解析服务暂不可用,已用本地解析')
+      }
+    },
+    // B3-2:草稿不存在或已过期 → 基于 fileUrl 重新建草稿并解析,不重新传 PDF
+    async reparseExpiredDraft() {
+      var fileUrl = this.previewResult && this.previewResult.fileUrl
+      if (!fileUrl) {
+        this.$message.error('草稿已过期,且无法获取原文件,请重新上传')
+        return
+      }
+      var confirmed = false
+      try {
+        confirmed = await this.$confirm('草稿已过期,是否基于原文件重新解析?', '提示', {
+          confirmButtonText: '重新解析',
+          cancelButtonText: '取消',
+          type: 'warning'
+        })
+      } catch (e) {
+        confirmed = false
+      }
+      if (!confirmed) return
+      this.uploading = true
+      try {
+        var res = await proxyReparseReport({
+          fileUrl: fileUrl,
+          familyId: this.uploadForm.familyId,
+          subjectId: this.uploadForm.subjectId,
+          note: this.uploadForm.note
+        })
+        if (res.code === 200) {
+          this.previewResult = res.data
+          this.analysisResult = null
+          this.$message.success('已重新解析,请重新确认入库')
+          this.notifyParseFallback(res.data)
+        } else {
+          this.$message.error(res.message || '重新解析失败')
+        }
+      } catch (e) {
+        this.$message.error('重新解析失败: ' + (e.message || ''))
+      } finally {
+        this.uploading = false
+      }
+    },
+    isDraftExpired(message) {
+      return !!(message && (message.indexOf('草稿不存在') >= 0 || message.indexOf('已过期') >= 0))
+    },
     async loadProxyReports() {
       this.proxyListLoading = true
       try {

+ 67 - 2
cfc-web/src/views/teacher/ProxyReport.vue

@@ -168,7 +168,7 @@
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
-import { proxyParseReport, proxyConfirmReport, listHealthReports, getHealthReportDetail } from '@/api/admin'
+import { proxyParseReport, proxyReparseReport, proxyConfirmReport, listHealthReports, getHealthReportDetail } from '@/api/admin'
 import { getFamilyList } from '@/api/admin'
 import { getFamilyMemberList } from '@/api/admin'
 
@@ -260,6 +260,7 @@ export default {
         if (res.code === 200) {
           this.previewResult = res.data
           this.analysisResult = null
+          this.notifyParseFallback(res.data)
         } else {
           this.$message.error(res.message || '解析失败')
         }
@@ -283,8 +284,11 @@ export default {
         if (res.code === 200) {
           this.$message.success('报告入库成功')
           this.previewResult = null
-          this.analysisResult = res.data && res.data.aiAnalysis ? res.data.aiAnalysis : null
+          this.applyAiAnalysisResult(res.data)
           this.loadReports()
+        } else if (this.isDraftExpired(res.message)) {
+          // B3-2:草稿过期降级——提示重新上传,支持基于 fileUrl 重新解析(复用原文件)
+          this.reparseExpiredDraft()
         } else {
           this.$message.error(res.message || '确认失败')
         }
@@ -294,6 +298,67 @@ export default {
         this.confirming = false
       }
     },
+    // B3-1:按 B1-5 返回的 aiAnalysisStatus/aiAnalysisMessage 提示「AI 解读未完成:原因」而非静默
+    applyAiAnalysisResult(data) {
+      const aiStatus = data && data.aiAnalysisStatus
+      // 有解读结果即展示;状态缺失时按成功处理(兼容未带 status 的旧返回)
+      if ((aiStatus === 'success' || !aiStatus) && data && data.aiAnalysis) {
+        this.analysisResult = data.aiAnalysis
+        return
+      }
+      this.analysisResult = null
+      const reason = (data && data.aiAnalysisMessage) || (data && data.aiAnalysisStatus) || ''
+      this.$message.warning('AI解读未完成:' + (reason || '未知原因'))
+    },
+    // B3-3:LangGraph 不可用走本地解析时的提示文案
+    notifyParseFallback(data) {
+      if (data && data.parseFallback) {
+        this.$message.warning('解析服务暂不可用,已用本地解析')
+      }
+    },
+    // B3-2:草稿不存在或已过期 → 基于 fileUrl 重新建草稿并解析,不重新传 PDF
+    async reparseExpiredDraft() {
+      const fileUrl = this.previewResult && this.previewResult.fileUrl
+      if (!fileUrl) {
+        this.$message.error('草稿已过期,且无法获取原文件,请重新上传')
+        return
+      }
+      let confirmed = false
+      try {
+        confirmed = await this.$confirm('草稿已过期,是否基于原文件重新解析?', '提示', {
+          confirmButtonText: '重新解析',
+          cancelButtonText: '取消',
+          type: 'warning'
+        })
+      } catch (e) {
+        confirmed = false
+      }
+      if (!confirmed) return
+      this.uploading = true
+      try {
+        const res = await proxyReparseReport({
+          fileUrl: fileUrl,
+          familyId: this.uploadForm.familyId,
+          subjectId: this.uploadForm.subjectId,
+          note: this.uploadForm.note
+        })
+        if (res.code === 200) {
+          this.previewResult = res.data
+          this.analysisResult = null
+          this.$message.success('已重新解析,请重新确认入库')
+          this.notifyParseFallback(res.data)
+        } else {
+          this.$message.error(res.message || '重新解析失败')
+        }
+      } catch (e) {
+        this.$message.error('重新解析失败: ' + (e.message || ''))
+      } finally {
+        this.uploading = false
+      }
+    },
+    isDraftExpired(message) {
+      return !!(message && (message.indexOf('草稿不存在') >= 0 || message.indexOf('已过期') >= 0))
+    },
     async loadReports() {
       this.listLoading = true
       try {

+ 70 - 0
docs/deployment/report-parse-langgraph-env.md

@@ -0,0 +1,70 @@
+# 报告解析服务部署依赖(LangGraph + 共享 uploads 卷)
+
+> 任务:T29-B3-5(P2-8 环境依赖补强)
+> 适用:健康报告「代传」链路(`/api/health/report/proxy-parse` → LangGraph `/api/v1/report/parse`)及
+> 非代传两阶段链路(`/report/upload` + `/report/parse`)在**生产/容器化环境**部署时的强制检查项。
+
+## 背景
+
+Java 后端(Spring Boot,`cfc-backend`)解析健康报告 PDF 时,**优先调用 LangGraph 服务**
+(`cfc-langgraph`,FastAPI,端口 9000,生产域名 `ai.etotem.com.cn`)做结构化解析,失败才回退本地 Java 解析。
+
+- 后端配置:`cfc-backend/src/main/resources/application.yml` 的 `langgraph.base-url`
+  (默认 `http://localhost:9000`,仅开发环境可用)。
+- 文件通路:后端先把上传的 PDF 落盘/落 OSS,再经 `storageService.getFilePath(fileUrl)` 得到本地绝对路径,
+  传给 LangGraph 的 `file_path`。**LangGraph 容器必须能读到该路径**。
+
+## 强制检查项清单(部署前逐条确认,缺一不可)
+
+### 1. `LANGGRAPH_BASE_URL` 环境变量必须显式设置,禁止使用默认 localhost
+
+```yaml
+# application.yml(生产必须覆盖默认值)
+langgraph:
+  base-url: ${LANGGRAPH_BASE_URL:http://localhost:9000}
+```
+
+| 环境 | 要求 |
+|------|------|
+| 开发 | 可保持默认 `http://localhost:9000`(需本机已启动 `cfc-langgraph`) |
+| 生产/容器化 | **必须**设置 `LANGGRAPH_BASE_URL=https://ai.etotem.com.cn`(或内网 LangGraph 地址),否则后端会尝试连容器内 `localhost:9000` 而失败,报告解析退化为本地 Java 解析(功能可用但解析质量下降) |
+
+> 检查命令:确认部署清单/启动脚本中已注入 `LANGGRAPH_BASE_URL`,且值与 LangGraph 服务实际地址一致。
+
+### 2. uploads 目录必须与 LangGraph 容器共享同一 volume
+
+后端 `upload.base-dir`(默认 `/data/cfc-uploads`)存放上传文件;`getFilePath` 返回的绝对路径
+会被直接作为 LangGraph 请求体 `file_path`。若 Java 容器与 LangGraph 容器**不共享该目录**,
+LangGraph 侧 `File(file_path)` 不存在 → 解析失败 → 回退本地解析(或整链失败)。
+
+| 环境 | 要求 |
+|------|------|
+| 本地非容器 | 同一主机目录即可,无额外要求 |
+| 容器化 | `cfc-backend` 与 `cfc-langgraph` 两个容器**必须挂载同一个 host 目录**(如 `/data/cfc-uploads`)到各自容器内相同路径;`cfc-backend` 的 `upload.base-dir` 与 LangGraph 读取 `file_path` 的目录必须是**同一目录** |
+
+> 检查命令:
+> ```bash
+> # 确认两个容器挂载同一 uploads 卷
+> docker inspect cfc-backend   # 查看 Mounts → Source/Target
+> docker inspect cfc-langgraph # 查看 Mounts → Source/Target
+> # 两者 target(容器内路径)必须一致,且等于 upload.base-dir 解析后的绝对路径
+> ```
+
+### 3. `python.executable` 本地兜底依赖(非容器可跳过)
+
+本地(非容器)环境使用本地 Java 解析兜底时,`pdfParseService` 不依赖外部脚本;仅异步采集链路
+(`report.collect`)依赖 `python` 可执行文件。生产容器化环境无需该检查(LangGraph 承担解析)。
+
+## 环境变量一览(报告解析相关)
+
+| 变量 | 默认值 | 生产必须 | 说明 |
+|------|--------|:--------:|------|
+| `LANGGRAPH_BASE_URL` | `http://localhost:9000` | ✅ | LangGraph 报告解析服务地址(生产 `https://ai.etotem.com.cn`) |
+| `upload.base-dir` | `/data/cfc-uploads` | ✅ | 上传文件根目录,须与 LangGraph 容器共享 |
+| `storage.type` | `jdcloud` | — | `jdcloud`(OSS)或 `local`(本地磁盘);`local` 时共享卷要求更严格 |
+
+## 验收(部署后自检)
+
+1. 用一份真实募极生物肠道菌群报告 PDF 走 `/api/health/report/proxy-parse`,响应 `parseMethod="langgraph"`
+   (而非 `"local"`)→ 证明 LangGraph 通路可用。
+2. 若返回 `parseFallback=true`(`parseMethod="local"`),说明 LangGraph 未打通,按本清单第 1/2 项排查。