package com.lingyue.parse.service; import com.lingyue.parse.config.FileStorageProperties; import com.lingyue.parse.entity.ParseTask; import com.lingyue.parse.enums.FileType; import com.lingyue.parse.repository.ParseTaskRepository; import com.lingyue.parse.util.ErrorCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; /** * 解析服务 * * 负责管理解析任务、调用 OCR 服务以及将解析后的文本写入 TXT 文件。 */ @Slf4j @Service @RequiredArgsConstructor public class ParseService { private final ParseTaskRepository parseTaskRepository; private final PaddleOcrClient paddleOcrClient; private final PdfTextExtractionService pdfTextExtractionService; private final WordTextExtractionService wordTextExtractionService; private final ExcelTextExtractionService excelTextExtractionService; private final OcrResultParser ocrResultParser; private final LayoutAnalysisService layoutAnalysisService; private final FileStorageProperties fileStorageProperties; // 单体应用直接注入 Service,不使用 Feign Client private final com.lingyue.graph.service.TextStorageService textStorageService; /** * 根据ID获取解析任务 */ public ParseTask getParseTaskById(String taskId) { return parseTaskRepository.selectById(taskId); } /** * 根据文档ID获取解析任务 */ public ParseTask getParseTaskByDocumentId(String documentId) { return parseTaskRepository.findByDocumentId(documentId); } /** * 保存解析任务 */ public ParseTask saveParseTask(ParseTask parseTask) { if (parseTask.getId() == null) { parseTask.setId(java.util.UUID.randomUUID().toString().replace("-", "")); parseTask.setCreateTime(new java.util.Date()); parseTask.setStartedAt(new java.util.Date()); parseTaskRepository.insert(parseTask); } else { parseTask.setUpdateTime(new java.util.Date()); parseTaskRepository.updateById(parseTask); } return parseTask; } /** * 对指定文档执行解析并将结果写入 TXT 文件 * 根据文件类型选择不同的处理方式: * - PDF: 使用分页判断逻辑(有文本层直接提取,无文本层使用OCR) * - 图片: 使用OCR * - 其他: 使用OCR * * @param documentId 文档ID * @param sourceFilePath 原始文件路径 * @param fileType 文件类型 * @return 更新后的解析任务 */ public ParseTask parseAndSaveText(String documentId, String sourceFilePath, FileType fileType) { // 1. 初始化或更新解析任务 ParseTask task = getOrCreateTask(documentId); task.setStatus("processing"); task.setCurrentStep("parsing"); task.setProgress(10); saveParseTask(task); try { String plainText; // 2. 根据文件类型选择处理方式 if (fileType == FileType.PDF) { log.info("处理PDF文件: {}", sourceFilePath); task.setCurrentStep("pdf_extraction"); task.setProgress(20); saveParseTask(task); // PDF使用分页判断逻辑 plainText = pdfTextExtractionService.extractText(sourceFilePath); log.info("PDF提取完成,文本长度: {}", plainText.length()); } else if (fileType == FileType.WORD || fileType == FileType.WORD_OLD) { log.info("处理Word文件: {}", sourceFilePath); task.setCurrentStep("word_extraction"); task.setProgress(20); saveParseTask(task); // Word文档直接提取文本 plainText = wordTextExtractionService.extractText(sourceFilePath); log.info("Word提取完成,文本长度: {}", plainText.length()); } else if (fileType == FileType.EXCEL || fileType == FileType.EXCEL_OLD) { log.info("处理Excel文件: {}", sourceFilePath); task.setCurrentStep("excel_extraction"); task.setProgress(20); saveParseTask(task); // Excel表格直接提取文本 plainText = excelTextExtractionService.extractText(sourceFilePath); log.info("Excel提取完成,文本长度: {}", plainText.length()); } else if (fileType.isImage()) { log.info("处理图片文件: {}", sourceFilePath); task.setCurrentStep("ocr"); task.setProgress(20); saveParseTask(task); // 图片使用OCR String ocrResult = paddleOcrClient.ocrFile(sourceFilePath); plainText = ocrResultParser.parseText(ocrResult); } else { log.info("处理其他文件类型: {}, 使用OCR", fileType); task.setCurrentStep("ocr"); task.setProgress(20); saveParseTask(task); // 其他文件类型使用OCR String ocrResult = paddleOcrClient.ocrFile(sourceFilePath); plainText = ocrResultParser.parseText(ocrResult); } // 3. 将纯文本写入 TXT 文件 task.setCurrentStep("saving"); task.setProgress(80); saveParseTask(task); String textFilePath = buildTextFilePath(documentId); try { writeTextToFile(textFilePath, plainText); } catch (IOException ioException) { log.error("写入文本到 TXT 文件失败, path={}", textFilePath, ioException); throw new RuntimeException("写入文本失败: " + ioException.getMessage(), ioException); } log.info("文本已写入: {}", textFilePath); // 4. 版面分析 task.setCurrentStep("layout_analysis"); task.setProgress(85); saveParseTask(task); try { LayoutAnalysisService.LayoutAnalysisResult layoutResult = layoutAnalysisService.analyzeLayout(sourceFilePath, fileType, plainText); log.info("版面分析完成: 识别到 {} 个元素", layoutResult.getElementCount()); // 将版面分析结果保存到任务选项(可选,用于后续图节点构建) if (task.getOptions() == null) { task.setOptions(new java.util.HashMap<>()); } java.util.Map options = (java.util.Map) task.getOptions(); options.put("layoutAnalysis", layoutResult); } catch (Exception e) { log.warn("版面分析失败,但不影响主流程: documentId={}", documentId, e); // 版面分析失败不影响主流程,只记录警告日志 } // 5. 记录文本存储路径到数据库 task.setCurrentStep("recording"); task.setProgress(90); saveParseTask(task); try { recordTextStorage(documentId, textFilePath); } catch (Exception e) { log.warn("记录文本存储路径失败,但不影响主流程: documentId={}, filePath={}", documentId, textFilePath, e); // 记录失败不影响主流程,只记录警告日志 } // 6. 更新任务状态为完成 task.setStatus("completed"); task.setCurrentStep("completed"); task.setProgress(100); task.setCompletedAt(new java.util.Date()); saveParseTask(task); } catch (Exception e) { // 错误分类和处理 ErrorCategory errorCategory = ErrorCategory.categorize(e); String errorMessage = String.format("[%s] %s", errorCategory.getDescription(), e.getMessage()); log.error("执行解析任务失败, documentId={}, errorCategory={}, retryable={}", documentId, errorCategory.getDescription(), errorCategory.isRetryable(), e); task.setStatus("failed"); task.setCurrentStep("failed"); task.setErrorMessage(errorMessage); // 保存错误信息到任务选项 if (task.getOptions() == null) { task.setOptions(new java.util.HashMap<>()); } java.util.Map options = (java.util.Map) task.getOptions(); options.put("errorCategory", errorCategory.name()); options.put("retryable", errorCategory.isRetryable()); saveParseTask(task); throw e; } return task; } /** * 对指定文档执行 OCR 并将结果写入 TXT 文件(兼容旧接口) * * @param documentId 文档ID * @param sourceFilePath 原始文件路径 * @return 更新后的解析任务 */ @Deprecated public ParseTask runOcrAndSaveText(String documentId, String sourceFilePath) { // 自动检测文件类型 FileType fileType = detectFileType(sourceFilePath); return parseAndSaveText(documentId, sourceFilePath, fileType); } /** * 检测文件类型 */ private FileType detectFileType(String filePath) { File file = new File(filePath); String fileName = file.getName(); String extension = ""; if (fileName.contains(".")) { extension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); } return FileType.fromExtension(extension); } /** * 获取或创建解析任务 */ private ParseTask getOrCreateTask(String documentId) { ParseTask existing = parseTaskRepository.findByDocumentId(documentId); if (existing != null) { return existing; } ParseTask task = new ParseTask(); task.setDocumentId(documentId); task.setStatus("pending"); task.setProgress(0); return task; } /** * 根据文档ID构建 TXT 文件存储路径 */ private String buildTextFilePath(String documentId) { Path path = Path.of( fileStorageProperties.getTextPath(), documentId.substring(0, 2), documentId + ".txt" ); return path.toString(); } /** * 将纯文本写入 TXT 文件 * 对于大文件使用分块写入,避免内存溢出 */ private void writeTextToFile(String textFilePath, String content) throws IOException { Path path = Path.of(textFilePath); Files.createDirectories(path.getParent()); // 如果内容较大,使用分块写入 long contentSize = content.getBytes(StandardCharsets.UTF_8).length; if (contentSize > 50 * 1024 * 1024) { // 50MB log.info("文本内容较大 ({} MB),使用分块写入: {}", contentSize / (1024.0 * 1024.0), textFilePath); com.lingyue.parse.util.FileChunkProcessor.writeTextFileInChunks( textFilePath, content, 10 * 1024 * 1024); // 10MB块 } else { Files.writeString(path, content, StandardCharsets.UTF_8); } } /** * 从 OCR 返回结果中提取纯文本(兼容旧接口) * * @deprecated 使用 OcrResultParser.parseText() 替代 */ @Deprecated private String extractPlainTextFromOcrResult(String ocrResult) { return ocrResultParser.parseText(ocrResult); } /** * 记录文本存储路径到数据库并自动建立 RAG 索引 * 单体应用模式:直接调用 Service 层 * * @param documentId 文档ID * @param textFilePath 文本文件路径 */ private void recordTextStorage(String documentId, String textFilePath) { try { // 使用 saveAndIndex 方法,保存文本的同时自动建立 RAG 索引 textStorageService.saveAndIndex(documentId, textFilePath); log.info("文本存储路径记录并建立索引成功: documentId={}, filePath={}", documentId, textFilePath); } catch (Exception e) { log.error("记录文本存储路径异常: documentId={}, filePath={}", documentId, textFilePath, e); // 记录失败不影响主流程,只记录日志 } } }