Przeglądaj źródła

feat: 报告上传流程拆分 — 新增 /report/upload(仅上传)+ /report/parse(按草稿解析)端点,修复 uploadBaseDir BLOCKING-2 残留

Xiaogang Liao 1 miesiąc temu
rodzic
commit
78b72c14fd

+ 216 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -83,6 +83,9 @@ public class HealthReportController {
      * TODO(部署): 容器化部署时需确保 Java 上传目录(uploads/health-reports)
      * TODO(部署): 容器化部署时需确保 Java 上传目录(uploads/health-reports)
      * 对 LangGraph 容器可见(共享 volume 或主机路径挂载),否则 file_path 不可访问将回退本地解析。
      * 对 LangGraph 容器可见(共享 volume 或主机路径挂载),否则 file_path 不可访问将回退本地解析。
      */
      */
+    @Value("${upload.base-dir:/data/cfc-uploads}")
+    private String uploadBaseDir;
+
     @Value("${langgraph.base-url:http://localhost:9000}")
     @Value("${langgraph.base-url:http://localhost:9000}")
     private String langgraphBaseUrl;
     private String langgraphBaseUrl;
 
 
@@ -435,9 +438,220 @@ public class HealthReportController {
         }
         }
     }
     }
 
 
+    /**
+     * 两阶段入库 Phase 0: 仅上传文件,不解析。返回 draftId 和 fileUrl,
+     * 供前端展示「开始解析」按钮并导航至确认页触发异步解析。
+     */
+    @Operation(summary = "仅上传报告文件(不解析)")
+    @PostMapping("/report/upload")
+    public Result<Map<String, Object>> uploadOnly(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "type", defaultValue = "auto") String type,
+            @RequestParam(value = "familyId", required = false) Long familyId,
+            @RequestParam(value = "memberId", required = false) Long memberId,
+            @RequestAttribute("userId") Long userId) {
+
+        if ("tongue".equals(type)) {
+            if (memberId == null) {
+                return Result.error("memberId is required for tongue diagnosis");
+            }
+        }
+
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+
+        String contentType = file.getContentType();
+        String originalFilename = file.getOriginalFilename();
+
+        try {
+            // 图片类型:安全检测 + 保存
+            if ("image".equals(type) || isImageContent(contentType)) {
+                try {
+                    byte[] imageBytes = file.getBytes();
+                    if (!wechatService.checkImage(imageBytes)) {
+                        return Result.error("图片内容包含敏感信息,上传失败");
+                    }
+                } catch (IOException e) {
+                    return Result.error("图片读取失败: " + e.getMessage());
+                }
+            } else {
+                // PDF 类型校验
+                String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
+                if (!lcName.endsWith(".pdf")
+                        && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
+                    return Result.error("仅支持PDF文件");
+                }
+            }
+
+            String fileUrl = saveUploadFile(file, userId);
+            String reportType = familyId != null && !("pdf".equals(type) || "auto".equals(type))
+                    ? "physical_exam" : "gut_flora";
+
+            // 创建空载荷草稿(不含解析数据)
+            ParsedReportPayload.Payload emptyPayload = new ParsedReportPayload.Payload();
+            String payloadJson = objectMapper.writeValueAsString(emptyPayload);
+
+            HealthReportDraft draft = healthReportDraftService.createDraft(
+                    userId, familyId, reportType, fileUrl, originalFilename, payloadJson);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("draftId", draft.getId());
+            result.put("fileUrl", fileUrl);
+            result.put("originalFilename", originalFilename);
+            return Result.success(result);
+
+        } catch (IOException e) {
+            log.error("文件上传失败", e);
+            return Result.error("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 两阶段入库 Phase 1: 按 draftId 触发解析。读取已保存的文件,
+     * 执行 LangGraph / 本地 Java 解析,更新草稿载荷后返回完整解析结果。
+     */
+    @Operation(summary = "按草稿ID触发解析")
+    @PostMapping("/report/parse")
+    public Result<Map<String, Object>> parseByDraftId(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
+        if (draft == null) {
+            return Result.error("草稿不存在、已确认或已过期");
+        }
+        if (!draft.getUserId().equals(userId)) {
+            return Result.error("无权操作此草稿");
+        }
+
+        String fileUrl = draft.getFileUrl();
+        String originalFilename = draft.getOriginalFilename();
+        String absolutePath = uploadBaseDir + "/" + fileUrl;
+
+        try {
+            java.io.File localFile = new java.io.File(absolutePath);
+            if (!localFile.exists()) {
+                return Result.error("文件不存在或已过期: " + originalFilename);
+            }
+
+            // 尝试 LangGraph 远程解析
+            try {
+                Map<String, Object> lgRequest = new HashMap<>();
+                lgRequest.put("file_path", absolutePath);
+                lgRequest.put("user_id", userId);
+                if (familyId != null) {
+                    lgRequest.put("family_id", familyId);
+                }
+
+                ResponseEntity<Map> lgResponse = restTemplate.postForEntity(
+                        langgraphBaseUrl + "/api/v1/report/parse", lgRequest, Map.class);
+                Map<String, Object> lgBody = lgResponse.getBody();
+                if (lgBody != null && Integer.valueOf(200).equals(lgBody.get("code"))) {
+                    Map<String, Object> lgData = (Map<String, Object>) lgBody.get("data");
+                    if (lgData != null) {
+                        return buildParseResultFromLg(lgData, draft, familyId);
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("LangGraph 解析失败,回退到本地 Java 解析: {}", e.getMessage());
+            }
+
+            // Fallback: 本地 Java 解析
+            return parseLocalFallback(absolutePath, draft, familyId, userId);
+
+        } catch (Exception e) {
+            log.error("文件解析失败", e);
+            return Result.error("解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 构造 LangGraph 解析结果(复用 parsePreview 中的逻辑)
+     */
+    private Result<Map<String, Object>> buildParseResultFromLg(
+            Map<String, Object> lgData, HealthReportDraft draft,
+            Long familyId) throws Exception {
+        ParsedReportPayload.Payload lgPayload = buildPayloadFromLgData(lgData);
+        String payloadJson = objectMapper.writeValueAsString(lgPayload);
+        healthReportDraftService.updatePayload(draft.getId(), payloadJson);
+
+        Map<String, Object> overview = (Map<String, Object>) lgData.get("overview");
+        String personName = overview != null ? (String) overview.get("person_name") : null;
+        String gender = overview != null ? (String) overview.get("gender") : null;
+        Integer age = overview != null ? toInteger(overview.get("age")) : null;
+
+        MemberMatchResult matchResult = healthReportService.matchMemberByProfile(
+                personName, gender, age, familyId);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("draftId", draft.getId());
+        result.put("payload", lgPayload);
+        result.put("matchedMemberId", matchResult.getMatchedMemberId());
+        result.put("confidence", matchResult.getConfidence());
+        result.put("matchLabel", matchResult.getMatchLabel());
+        result.put("candidates", matchResult.getCandidates());
+        result.put("extractedName", personName);
+        result.put("extractedGender", gender);
+        result.put("extractedAge", age);
+        result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
+
+        try {
+            saveResultToJsonFile(result);
+        } catch (Exception e) {
+            log.warn("保存结果到 JSON 文件失败:{}", e.getMessage());
+        }
+
+        return Result.success(result);
+    }
+
+    /**
+     * 本地 Java 解析回退
+     */
+    private Result<Map<String, Object>> parseLocalFallback(
+            String absolutePath, HealthReportDraft draft, Long familyId, Long userId)
+            throws Exception {
+
+        try (java.io.FileInputStream fis = new java.io.FileInputStream(absolutePath)) {
+            ParsedReportResult parsed = pdfParseService.parse(fis);
+            if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
+                return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
+            }
+
+            ParsedReportPayload.Payload payload = convertToPayload(parsed);
+            String payloadJson = objectMapper.writeValueAsString(payload);
+            healthReportDraftService.updatePayload(draft.getId(), payloadJson);
+
+            MemberMatchResult matchResult = healthReportService.matchMemberByProfile(
+                    parsed.getPersonName(), parsed.getGender(), parsed.getAge(), familyId);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("draftId", draft.getId());
+            result.put("payload", payload);
+            result.put("matchedMemberId", matchResult.getMatchedMemberId());
+            result.put("confidence", matchResult.getConfidence());
+            result.put("matchLabel", matchResult.getMatchLabel());
+            result.put("candidates", matchResult.getCandidates());
+            result.put("extractedName", parsed.getPersonName());
+            result.put("extractedGender", parsed.getGender());
+            result.put("extractedAge", parsed.getAge());
+            result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
+            return Result.success(result);
+        }
+    }
+
     /**
     /**
      * 两阶段入库 Phase 1: 上传PDF → 解析 → 存草稿 → 返回draftId + 解析载荷 + 成员匹配
      * 两阶段入库 Phase 1: 上传PDF → 解析 → 存草稿 → 返回draftId + 解析载荷 + 成员匹配
      * 不落库到 health_reports 等正式表。支持 PDF 解析(auto)和图片上传(image)。
      * 不落库到 health_reports 等正式表。支持 PDF 解析(auto)和图片上传(image)。
+     *
+     * @deprecated 使用 /report/upload(仅上传)+ /report/parse(按草稿解析)替代
      */
      */
     @Operation(summary = "上传解析报告(预览,不入库)")
     @Operation(summary = "上传解析报告(预览,不入库)")
     @PostMapping("/report/parse-preview")
     @PostMapping("/report/parse-preview")
@@ -503,7 +717,7 @@ public class HealthReportController {
 
 
             // 保存文件到磁盘(LangGraph 和本地解析共用)
             // 保存文件到磁盘(LangGraph 和本地解析共用)
             String fileUrl = saveUploadFile(file, userId);
             String fileUrl = saveUploadFile(file, userId);
-            String absolutePath = new File(fileUrl).getAbsolutePath();
+            String absolutePath = uploadBaseDir + "/" + fileUrl;
 
 
             // 尝试 LangGraph 远程解析
             // 尝试 LangGraph 远程解析
             try {
             try {
@@ -1541,7 +1755,7 @@ public class HealthReportController {
      * 保存上传文件到本地存储
      * 保存上传文件到本地存储
      */
      */
     private String saveUploadFile(MultipartFile file, Long ownerId) throws IOException {
     private String saveUploadFile(MultipartFile file, Long ownerId) throws IOException {
-        String uploadDir = "uploads" + File.separator + "health-reports" + File.separator + ownerId;
+        String uploadDir = uploadBaseDir + File.separator + "uploads" + File.separator + "health-reports" + File.separator + ownerId;
         File dir = new File(uploadDir);
         File dir = new File(uploadDir);
         if (!dir.exists()) {
         if (!dir.exists()) {
             dir.mkdirs();
             dir.mkdirs();

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -102,8 +102,15 @@ public class HealthReportDraftService {
     }
     }
 
 
     /**
     /**
-     * 更新代上传备注
+     * 更新草稿载荷 JSON(解析完成后写入)
      */
      */
+    public void updatePayload(Long draftId, String payloadJson) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getPayloadJson, payloadJson)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
     public void updateNote(Long draftId, String note) {
     public void updateNote(Long draftId, String note) {
         LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
         LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
         uw.eq(HealthReportDraft::getId, draftId)
         uw.eq(HealthReportDraft::getId, draftId)