ParseService.java 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. package com.lingyue.parse.service;
  2. import com.lingyue.parse.config.FileStorageProperties;
  3. import com.lingyue.parse.entity.ParseTask;
  4. import com.lingyue.parse.enums.FileType;
  5. import com.lingyue.parse.repository.ParseTaskRepository;
  6. import com.lingyue.parse.util.ErrorCategory;
  7. import lombok.RequiredArgsConstructor;
  8. import lombok.extern.slf4j.Slf4j;
  9. import org.springframework.stereotype.Service;
  10. import java.io.File;
  11. import java.io.IOException;
  12. import java.nio.charset.StandardCharsets;
  13. import java.nio.file.Files;
  14. import java.nio.file.Path;
  15. /**
  16. * 解析服务
  17. *
  18. * 负责管理解析任务、调用 OCR 服务以及将解析后的文本写入 TXT 文件。
  19. */
  20. @Slf4j
  21. @Service
  22. @RequiredArgsConstructor
  23. public class ParseService {
  24. private final ParseTaskRepository parseTaskRepository;
  25. private final PaddleOcrClient paddleOcrClient;
  26. private final PdfTextExtractionService pdfTextExtractionService;
  27. private final WordTextExtractionService wordTextExtractionService;
  28. private final ExcelTextExtractionService excelTextExtractionService;
  29. private final OcrResultParser ocrResultParser;
  30. private final LayoutAnalysisService layoutAnalysisService;
  31. private final FileStorageProperties fileStorageProperties;
  32. // 单体应用直接注入 Service,不使用 Feign Client
  33. private final com.lingyue.graph.service.TextStorageService textStorageService;
  34. /**
  35. * 根据ID获取解析任务
  36. */
  37. public ParseTask getParseTaskById(String taskId) {
  38. return parseTaskRepository.selectById(taskId);
  39. }
  40. /**
  41. * 根据文档ID获取解析任务
  42. */
  43. public ParseTask getParseTaskByDocumentId(String documentId) {
  44. return parseTaskRepository.findByDocumentId(documentId);
  45. }
  46. /**
  47. * 保存解析任务
  48. */
  49. public ParseTask saveParseTask(ParseTask parseTask) {
  50. if (parseTask.getId() == null) {
  51. parseTask.setId(java.util.UUID.randomUUID().toString().replace("-", ""));
  52. parseTask.setCreateTime(new java.util.Date());
  53. parseTask.setStartedAt(new java.util.Date());
  54. parseTaskRepository.insert(parseTask);
  55. } else {
  56. parseTask.setUpdateTime(new java.util.Date());
  57. parseTaskRepository.updateById(parseTask);
  58. }
  59. return parseTask;
  60. }
  61. /**
  62. * 对指定文档执行解析并将结果写入 TXT 文件
  63. * 根据文件类型选择不同的处理方式:
  64. * - PDF: 使用分页判断逻辑(有文本层直接提取,无文本层使用OCR)
  65. * - 图片: 使用OCR
  66. * - 其他: 使用OCR
  67. *
  68. * @param documentId 文档ID
  69. * @param sourceFilePath 原始文件路径
  70. * @param fileType 文件类型
  71. * @return 更新后的解析任务
  72. */
  73. public ParseTask parseAndSaveText(String documentId, String sourceFilePath, FileType fileType) {
  74. // 1. 初始化或更新解析任务
  75. ParseTask task = getOrCreateTask(documentId);
  76. task.setStatus("processing");
  77. task.setCurrentStep("parsing");
  78. task.setProgress(10);
  79. saveParseTask(task);
  80. try {
  81. String plainText;
  82. // 2. 根据文件类型选择处理方式
  83. if (fileType == FileType.PDF) {
  84. log.info("处理PDF文件: {}", sourceFilePath);
  85. task.setCurrentStep("pdf_extraction");
  86. task.setProgress(20);
  87. saveParseTask(task);
  88. // PDF使用分页判断逻辑
  89. plainText = pdfTextExtractionService.extractText(sourceFilePath);
  90. log.info("PDF提取完成,文本长度: {}", plainText.length());
  91. } else if (fileType == FileType.WORD || fileType == FileType.WORD_OLD) {
  92. log.info("处理Word文件: {}", sourceFilePath);
  93. task.setCurrentStep("word_extraction");
  94. task.setProgress(20);
  95. saveParseTask(task);
  96. // Word文档直接提取文本
  97. plainText = wordTextExtractionService.extractText(sourceFilePath);
  98. log.info("Word提取完成,文本长度: {}", plainText.length());
  99. } else if (fileType == FileType.EXCEL || fileType == FileType.EXCEL_OLD) {
  100. log.info("处理Excel文件: {}", sourceFilePath);
  101. task.setCurrentStep("excel_extraction");
  102. task.setProgress(20);
  103. saveParseTask(task);
  104. // Excel表格直接提取文本
  105. plainText = excelTextExtractionService.extractText(sourceFilePath);
  106. log.info("Excel提取完成,文本长度: {}", plainText.length());
  107. } else if (fileType.isImage()) {
  108. log.info("处理图片文件: {}", sourceFilePath);
  109. task.setCurrentStep("ocr");
  110. task.setProgress(20);
  111. saveParseTask(task);
  112. // 图片使用OCR
  113. String ocrResult = paddleOcrClient.ocrFile(sourceFilePath);
  114. plainText = ocrResultParser.parseText(ocrResult);
  115. } else {
  116. log.info("处理其他文件类型: {}, 使用OCR", fileType);
  117. task.setCurrentStep("ocr");
  118. task.setProgress(20);
  119. saveParseTask(task);
  120. // 其他文件类型使用OCR
  121. String ocrResult = paddleOcrClient.ocrFile(sourceFilePath);
  122. plainText = ocrResultParser.parseText(ocrResult);
  123. }
  124. // 3. 将纯文本写入 TXT 文件
  125. task.setCurrentStep("saving");
  126. task.setProgress(80);
  127. saveParseTask(task);
  128. String textFilePath = buildTextFilePath(documentId);
  129. try {
  130. writeTextToFile(textFilePath, plainText);
  131. } catch (IOException ioException) {
  132. log.error("写入文本到 TXT 文件失败, path={}", textFilePath, ioException);
  133. throw new RuntimeException("写入文本失败: " + ioException.getMessage(), ioException);
  134. }
  135. log.info("文本已写入: {}", textFilePath);
  136. // 4. 版面分析
  137. task.setCurrentStep("layout_analysis");
  138. task.setProgress(85);
  139. saveParseTask(task);
  140. try {
  141. LayoutAnalysisService.LayoutAnalysisResult layoutResult =
  142. layoutAnalysisService.analyzeLayout(sourceFilePath, fileType, plainText);
  143. log.info("版面分析完成: 识别到 {} 个元素", layoutResult.getElementCount());
  144. // 将版面分析结果保存到任务选项(可选,用于后续图节点构建)
  145. if (task.getOptions() == null) {
  146. task.setOptions(new java.util.HashMap<>());
  147. }
  148. java.util.Map<String, Object> options = (java.util.Map<String, Object>) task.getOptions();
  149. options.put("layoutAnalysis", layoutResult);
  150. } catch (Exception e) {
  151. log.warn("版面分析失败,但不影响主流程: documentId={}", documentId, e);
  152. // 版面分析失败不影响主流程,只记录警告日志
  153. }
  154. // 5. 记录文本存储路径到数据库
  155. task.setCurrentStep("recording");
  156. task.setProgress(90);
  157. saveParseTask(task);
  158. try {
  159. recordTextStorage(documentId, textFilePath);
  160. } catch (Exception e) {
  161. log.warn("记录文本存储路径失败,但不影响主流程: documentId={}, filePath={}", documentId, textFilePath, e);
  162. // 记录失败不影响主流程,只记录警告日志
  163. }
  164. // 6. 更新任务状态为完成
  165. task.setStatus("completed");
  166. task.setCurrentStep("completed");
  167. task.setProgress(100);
  168. task.setCompletedAt(new java.util.Date());
  169. saveParseTask(task);
  170. } catch (Exception e) {
  171. // 错误分类和处理
  172. ErrorCategory errorCategory = ErrorCategory.categorize(e);
  173. String errorMessage = String.format("[%s] %s", errorCategory.getDescription(), e.getMessage());
  174. log.error("执行解析任务失败, documentId={}, errorCategory={}, retryable={}",
  175. documentId, errorCategory.getDescription(), errorCategory.isRetryable(), e);
  176. task.setStatus("failed");
  177. task.setCurrentStep("failed");
  178. task.setErrorMessage(errorMessage);
  179. // 保存错误信息到任务选项
  180. if (task.getOptions() == null) {
  181. task.setOptions(new java.util.HashMap<>());
  182. }
  183. java.util.Map<String, Object> options = (java.util.Map<String, Object>) task.getOptions();
  184. options.put("errorCategory", errorCategory.name());
  185. options.put("retryable", errorCategory.isRetryable());
  186. saveParseTask(task);
  187. throw e;
  188. }
  189. return task;
  190. }
  191. /**
  192. * 对指定文档执行 OCR 并将结果写入 TXT 文件(兼容旧接口)
  193. *
  194. * @param documentId 文档ID
  195. * @param sourceFilePath 原始文件路径
  196. * @return 更新后的解析任务
  197. */
  198. @Deprecated
  199. public ParseTask runOcrAndSaveText(String documentId, String sourceFilePath) {
  200. // 自动检测文件类型
  201. FileType fileType = detectFileType(sourceFilePath);
  202. return parseAndSaveText(documentId, sourceFilePath, fileType);
  203. }
  204. /**
  205. * 检测文件类型
  206. */
  207. private FileType detectFileType(String filePath) {
  208. File file = new File(filePath);
  209. String fileName = file.getName();
  210. String extension = "";
  211. if (fileName.contains(".")) {
  212. extension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
  213. }
  214. return FileType.fromExtension(extension);
  215. }
  216. /**
  217. * 获取或创建解析任务
  218. */
  219. private ParseTask getOrCreateTask(String documentId) {
  220. ParseTask existing = parseTaskRepository.findByDocumentId(documentId);
  221. if (existing != null) {
  222. return existing;
  223. }
  224. ParseTask task = new ParseTask();
  225. task.setDocumentId(documentId);
  226. task.setStatus("pending");
  227. task.setProgress(0);
  228. return task;
  229. }
  230. /**
  231. * 根据文档ID构建 TXT 文件存储路径
  232. */
  233. private String buildTextFilePath(String documentId) {
  234. Path path = Path.of(
  235. fileStorageProperties.getTextPath(),
  236. documentId.substring(0, 2),
  237. documentId + ".txt"
  238. );
  239. return path.toString();
  240. }
  241. /**
  242. * 将纯文本写入 TXT 文件
  243. * 对于大文件使用分块写入,避免内存溢出
  244. */
  245. private void writeTextToFile(String textFilePath, String content) throws IOException {
  246. Path path = Path.of(textFilePath);
  247. Files.createDirectories(path.getParent());
  248. // 如果内容较大,使用分块写入
  249. long contentSize = content.getBytes(StandardCharsets.UTF_8).length;
  250. if (contentSize > 50 * 1024 * 1024) { // 50MB
  251. log.info("文本内容较大 ({} MB),使用分块写入: {}", contentSize / (1024.0 * 1024.0), textFilePath);
  252. com.lingyue.parse.util.FileChunkProcessor.writeTextFileInChunks(
  253. textFilePath, content, 10 * 1024 * 1024); // 10MB块
  254. } else {
  255. Files.writeString(path, content, StandardCharsets.UTF_8);
  256. }
  257. }
  258. /**
  259. * 从 OCR 返回结果中提取纯文本(兼容旧接口)
  260. *
  261. * @deprecated 使用 OcrResultParser.parseText() 替代
  262. */
  263. @Deprecated
  264. private String extractPlainTextFromOcrResult(String ocrResult) {
  265. return ocrResultParser.parseText(ocrResult);
  266. }
  267. /**
  268. * 记录文本存储路径到数据库并自动建立 RAG 索引
  269. * 单体应用模式:直接调用 Service 层
  270. *
  271. * @param documentId 文档ID
  272. * @param textFilePath 文本文件路径
  273. */
  274. private void recordTextStorage(String documentId, String textFilePath) {
  275. try {
  276. // 使用 saveAndIndex 方法,保存文本的同时自动建立 RAG 索引
  277. textStorageService.saveAndIndex(documentId, textFilePath);
  278. log.info("文本存储路径记录并建立索引成功: documentId={}, filePath={}", documentId, textFilePath);
  279. } catch (Exception e) {
  280. log.error("记录文本存储路径异常: documentId={}, filePath={}", documentId, textFilePath, e);
  281. // 记录失败不影响主流程,只记录日志
  282. }
  283. }
  284. }