ParseService.java 14 KB

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