Browse Source

fix(report-parse): 报告上传改走 JD Cloud OSS,LangGraph 解析支持远程 URL

问题:生产环境 storage.type=jdcloud,但 HealthReportController 的
saveUploadFile 仍写本地磁盘,LangGraph 容器读不到文件(400 文件不存在)。

修复:
1. HealthReportController 注入 StorageService,uploadOnly/uploadReport/
   parsePreview 改用 storageService.storeFile → 文件上传到京东 OSS
2. parseByDraftId/parsePreview 对远程 OSS URL 直接传 URL 给 LangGraph,
   本地路径才用 getFilePath(); 本地 Java fallback 用 getFilePath 下载
3. 删除不再使用的 saveUploadFile/resolveUploadFilePath 私有方法
4. LangGraph report_parse.py 的 file_path 支持 http(s) URL,自动下载到
   临时文件后解析(不依赖共享 volume 时序)
Xiaogang Liao 1 month ago
parent
commit
dff6a0cad8

+ 22 - 68
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -36,6 +36,7 @@ import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.ReportBlockAssembler;
 import com.etotem.cfc.service.ReportBlockService;
+import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.TongueDiagnosisService;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -142,6 +143,9 @@ public class HealthReportController {
     @Resource
     private ReportBlockService reportBlockService;
 
+    @Resource
+    private StorageService storageService;
+
     @Resource
     private ReportBlockAssembler reportBlockAssembler;
 
@@ -403,7 +407,7 @@ public class HealthReportController {
                 return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
             }
 
-            String fileUrl = saveUploadFile(file, userId);
+            String fileUrl = storageService.storeFile(file, "health-reports");
 
             ParsedReportPayload.Payload payload = convertToPayload(parsed);
             String payloadJson = objectMapper.writeValueAsString(payload);
@@ -512,7 +516,7 @@ public class HealthReportController {
                 }
             }
 
-            String fileUrl = saveUploadFile(file, userId);
+            String fileUrl = storageService.storeFile(file, "health-reports");
             String reportType = familyId != null && !("pdf".equals(type) || "auto".equals(type))
                     ? "physical_exam" : "gut_flora";
 
@@ -562,18 +566,14 @@ public class HealthReportController {
 
         String fileUrl = draft.getFileUrl();
         String originalFilename = draft.getOriginalFilename();
-        String absolutePath = resolveUploadFilePath(fileUrl);
 
         try {
-            java.io.File localFile = new java.io.File(absolutePath);
-            if (!localFile.exists()) {
-                return Result.error("文件不存在或已过期: " + originalFilename);
-            }
-
-            // 尝试 LangGraph 远程解析
+            // LangGraph 优先:远程 OSS URL 直接传 URL(LangGraph 自行下载),本地路径传共享卷路径
             try {
+                String lgPath = (fileUrl != null && fileUrl.startsWith("http"))
+                        ? fileUrl : storageService.getFilePath(fileUrl);
                 Map<String, Object> lgRequest = new HashMap<>();
-                lgRequest.put("file_path", absolutePath);
+                lgRequest.put("file_path", lgPath);
                 lgRequest.put("user_id", userId);
                 if (familyId != null) {
                     lgRequest.put("family_id", familyId);
@@ -592,7 +592,12 @@ public class HealthReportController {
                 log.warn("LangGraph 解析失败,回退到本地 Java 解析: {}", e.getMessage());
             }
 
-            // Fallback: 本地 Java 解析
+            // Fallback: 本地 Java 解析(下载 OSS 文件到本地临时目录,或直接读共享卷)
+            String absolutePath = storageService.getFilePath(fileUrl);
+            java.io.File localFile = new java.io.File(absolutePath);
+            if (!localFile.exists()) {
+                return Result.error("文件不存在或已过期: " + originalFilename);
+            }
             return parseLocalFallback(absolutePath, draft, familyId, userId);
 
         } catch (Exception e) {
@@ -718,7 +723,7 @@ public class HealthReportController {
                     return Result.error("图片读取失败: " + e.getMessage());
                 }
 
-                String fileUrl = saveUploadFile(file, userId);
+                String fileUrl = storageService.storeFile(file, "health-reports");
                 String reportType = familyId != null ? "physical_exam" : "gut_flora";
 
                 // 创建空载荷草稿,后续由 confirm 阶段用户补充
@@ -743,14 +748,14 @@ public class HealthReportController {
                 return Result.error("仅支持PDF文件");
             }
 
-            // 保存文件到磁盘(LangGraph 和本地解析共用
-            String fileUrl = saveUploadFile(file, userId);
-            String absolutePath = resolveUploadFilePath(fileUrl);
+            // 保存文件(通过 StorageService 支持本地磁盘或京东云 OSS
+            String fileUrl = storageService.storeFile(file, "health-reports");
+            boolean isRemote = fileUrl != null && fileUrl.startsWith("http");
 
-            // 尝试 LangGraph 远程解析
+            // 尝试 LangGraph 远程解析(远程 OSS URL 直接传 URL,LangGraph 自行下载)
             try {
                 Map<String, Object> lgRequest = new HashMap<>();
-                lgRequest.put("file_path", absolutePath);
+                lgRequest.put("file_path", isRemote ? fileUrl : storageService.getFilePath(fileUrl));
                 lgRequest.put("user_id", userId);
                 if (familyId != null) {
                     lgRequest.put("family_id", familyId);
@@ -1794,57 +1799,6 @@ public class HealthReportController {
         log.info(summary);
     }
 
-    /**
-     * 解析草稿文件在服务器上的绝对路径。
-     * 兼容两种存储格式:
-     * - 当前格式:saveUploadFile 返回的绝对路径(已含 uploadBaseDir 前缀)
-     * - 历史格式:相对路径(如 uploads/health-reports/...)
-     * 修复:parse/parse-preview 曾对绝对路径再次拼接 uploadBaseDir 导致文件找不到。
-     */
-    private String resolveUploadFilePath(String fileUrl) {
-        if (fileUrl == null || fileUrl.isEmpty()) {
-            return null;
-        }
-        String normalized = fileUrl.replace("\\", "/");
-        if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
-            // 已是绝对路径,直接使用
-            return normalized;
-        }
-        // 相对路径,拼接上传根目录
-        return uploadBaseDir + "/" + normalized;
-    }
-
-    /**
-     * 保存上传文件到本地存储
-     */
-    private String saveUploadFile(MultipartFile file, Long ownerId) throws IOException {
-        String uploadDir = uploadBaseDir + File.separator + "uploads" + File.separator + "health-reports" + File.separator + ownerId;
-        File dir = new File(uploadDir);
-        if (!dir.exists()) {
-            dir.mkdirs();
-        }
-    
-        String originalFilename = file.getOriginalFilename();
-        if (originalFilename == null) {
-            originalFilename = "report.pdf";
-        }
-    
-        // 文件名:timestamp_uuid.extension
-        String extension = "";
-        int dotIdx = originalFilename.lastIndexOf('.');
-        if (dotIdx > 0) {
-            extension = originalFilename.substring(dotIdx);
-        }
-        String savedFilename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + extension;
-        Path filePath = Paths.get(uploadDir, savedFilename);
-
-        Files.copy(file.getInputStream(), filePath);
-
-        String relativePath = uploadDir.replace("\\", "/") + "/" + savedFilename;
-        log.info("报告文件已保存: {}", relativePath);
-        return relativePath;
-    }
-
     /**
      * 检查是否为授权代上传角色
      */

+ 96 - 0
cfc-langgraph/app/api/report_parse.py

@@ -0,0 +1,96 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional, Any
+from app.agents.report_parse_agent import ReportParseAgent
+import logging
+import os
+import json
+import tempfile
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["report_parse"])
+_agent = None
+
+
+def get_agent():
+    global _agent
+    if _agent is None:
+        _agent = ReportParseAgent()
+    return _agent
+
+
+class ParseRequest(BaseModel):
+    file_path: str
+    family_id: Optional[int] = None
+    user_id: Optional[int] = None
+
+
+class ParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+async def resolve_local_path(file_path: str) -> str:
+    """将 file_path 解析为本地文件路径。
+    支持:本地路径、/uploads 共享卷路径、http(s) 远程 OSS URL(下载到临时文件)。"""
+    if file_path.startswith(('http://', 'https://')):
+        import httpx
+        logger.info("report_parse: 远程文件 %s,下载到临时文件", file_path)
+        async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
+            resp = await client.get(file_path)
+            resp.raise_for_status()
+        ext = os.path.splitext(file_path.split('?')[0])[1] or '.pdf'
+        fd, tmp = tempfile.mkstemp(suffix=ext)
+        with os.fdopen(fd, 'wb') as f:
+            f.write(resp.content)
+        logger.info("report_parse: 下载完成 -> %s", tmp)
+        return tmp
+    return file_path
+
+
+@router.post("/report/parse", response_model=ParseResponse)
+async def parse_report(req: ParseRequest):
+    local = await resolve_local_path(req.file_path)
+    if not os.path.exists(local):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    logger.info("report_parse: file_path=%s family_id=%s user_id=%s", local, req.family_id, req.user_id)
+    agent = get_agent()
+    try:
+        result = await agent.parse(local)
+        return ParseResponse(data=result)
+    except Exception as e:
+        logger.error("报告解析失败: %s", e, exc_info=True)
+        return ParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+
+
+class GenericParseRequest(BaseModel):
+    file_path: str
+    extra_context: Optional[dict] = None
+
+
+class GenericParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+@router.post("/report/parse-generic", response_model=GenericParseResponse)
+async def parse_report_generic(req: GenericParseRequest):
+    """通用报告 LLM 兜底解析。
+
+    适用于指纹检测未匹配的未知类型报告。
+    直接交给 LLM 提取结构化数据,不经过算法预解析。
+    """
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    logger.info("report_parse_generic: file_path=%s", req.file_path)
+    agent = get_agent()
+    try:
+        result = await agent.parse_generic(req.file_path, req.extra_context)
+        return GenericParseResponse(data=result)
+    except Exception as e:
+        logger.error("通用报告解析失败: %s", e, exc_info=True)
+        return GenericParseResponse(code=500, message=f"解析失败: {str(e)}", data={})