Procházet zdrojové kódy

feat(parse-service): 完善parse-service模块,实现P0和P1任务

主要功能:
1. PDF文本提取:实现分页判断逻辑(有文本层直接提取,无文本层使用OCR)
2. Word文档提取:支持.docx和.doc格式的直接文本提取
3. Excel表格提取:支持.xlsx和.xls格式的表格数据提取
4. OCR结果解析:使用Jackson解析OCR返回的JSON结果
5. 文本存储路径记录:通过Feign Client调用graph-service记录text_storage
6. 版面分析:识别文档结构(标题、段落、表格等)并提取位置信息

新增文件:
- WordTextExtractionService: Word文档文本提取服务
- ExcelTextExtractionService: Excel表格提取服务
- PdfTextExtractionService: PDF分页判断和文本提取服务
- OcrResultParser: OCR结果JSON解析器
- LayoutAnalysisService: 版面分析服务
- GraphServiceClient: graph-service的Feign客户端
- TextStorageController/Service: graph-service中的文本存储管理

依赖更新:
- 添加Apache POI 5.2.5(Word/Excel处理)
- 添加Apache PDFBox 3.0.1(PDF处理)

完成度:约95%(P0和P1任务全部完成)
何文松 před 1 měsícem
rodič
revize
ff821c338c

+ 81 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/TextStorageController.java

@@ -0,0 +1,81 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.entity.TextStorage;
+import com.lingyue.graph.service.TextStorageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 文本存储控制器
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/graph")
+@RequiredArgsConstructor
+@Tag(name = "文本存储", description = "文本存储路径管理接口")
+public class TextStorageController {
+    
+    private final TextStorageService textStorageService;
+    
+    /**
+     * 保存文本存储记录
+     * 
+     * @param request 请求参数
+     * @return 操作结果
+     */
+    @PostMapping("/text-storage")
+    @Operation(summary = "保存文本存储记录", description = "记录TXT文件存储路径到数据库")
+    public AjaxResult<TextStorage> saveTextStorage(
+            @Parameter(description = "请求参数", required = true)
+            @RequestBody Map<String, Object> request) {
+        
+        String documentId = (String) request.get("documentId");
+        String filePath = (String) request.get("filePath");
+        
+        if (documentId == null || documentId.isEmpty()) {
+            return AjaxResult.error("文档ID不能为空");
+        }
+        
+        if (filePath == null || filePath.isEmpty()) {
+            return AjaxResult.error("文件路径不能为空");
+        }
+        
+        try {
+            TextStorage textStorage = textStorageService.saveTextStorage(documentId, filePath);
+            return AjaxResult.success(textStorage);
+        } catch (Exception e) {
+            log.error("保存文本存储记录失败: documentId={}, filePath={}", documentId, filePath, e);
+            return AjaxResult.error("保存文本存储记录失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 根据文档ID查询文本存储记录
+     * 
+     * @param documentId 文档ID
+     * @return 文本存储记录
+     */
+    @GetMapping("/text-storage/{documentId}")
+    @Operation(summary = "查询文本存储记录", description = "根据文档ID查询文本存储路径")
+    public AjaxResult<TextStorage> getTextStorage(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        TextStorage textStorage = textStorageService.getByDocumentId(documentId);
+        if (textStorage == null) {
+            return AjaxResult.error("文本存储记录不存在");
+        }
+        
+        return AjaxResult.success(textStorage);
+    }
+}

+ 101 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/TextStorageService.java

@@ -0,0 +1,101 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.common.exception.ServiceException;
+import com.lingyue.graph.entity.TextStorage;
+import com.lingyue.graph.repository.TextStorageRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.UUID;
+
+/**
+ * 文本存储服务
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TextStorageService {
+    
+    private final TextStorageRepository textStorageRepository;
+    
+    /**
+     * 保存文本存储记录
+     * 
+     * @param documentId 文档ID
+     * @param filePath TXT文件路径
+     * @return 文本存储记录
+     */
+    public TextStorage saveTextStorage(String documentId, String filePath) {
+        // 检查文件是否存在
+        File file = new File(filePath);
+        if (!file.exists()) {
+            throw new ServiceException("文本文件不存在: " + filePath);
+        }
+        
+        // 检查是否已存在记录
+        TextStorage existing = textStorageRepository.findByDocumentId(documentId);
+        if (existing != null) {
+            // 更新现有记录
+            existing.setFilePath(filePath);
+            existing.setFileSize(file.length());
+            existing.setChecksum(calculateChecksum(filePath));
+            existing.setUpdateTime(new java.util.Date());
+            textStorageRepository.updateById(existing);
+            log.info("更新文本存储记录: documentId={}, filePath={}", documentId, filePath);
+            return existing;
+        }
+        
+        // 创建新记录
+        TextStorage textStorage = new TextStorage();
+        textStorage.setId(UUID.randomUUID().toString().replace("-", ""));
+        textStorage.setDocumentId(documentId);
+        textStorage.setFilePath(filePath);
+        textStorage.setFileSize(file.length());
+        textStorage.setChecksum(calculateChecksum(filePath));
+        textStorage.setCreateTime(new java.util.Date());
+        textStorage.setUpdateTime(new java.util.Date());
+        
+        textStorageRepository.insert(textStorage);
+        log.info("创建文本存储记录: documentId={}, filePath={}", documentId, filePath);
+        
+        return textStorage;
+    }
+    
+    /**
+     * 根据文档ID查询文本存储记录
+     * 
+     * @param documentId 文档ID
+     * @return 文本存储记录
+     */
+    public TextStorage getByDocumentId(String documentId) {
+        return textStorageRepository.findByDocumentId(documentId);
+    }
+    
+    /**
+     * 计算文件MD5校验和
+     */
+    private String calculateChecksum(String filePath) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] fileBytes = Files.readAllBytes(Path.of(filePath));
+            byte[] digest = md.digest(fileBytes);
+            
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            log.warn("计算文件校验和失败: {}", filePath, e);
+            return null;
+        }
+    }
+}

+ 261 - 0
backend/parse-service/TODO.md

@@ -0,0 +1,261 @@
+# parse-service 待完善清单
+
+## 📋 总体状态
+
+**完成度:约 95%**(P0和P1任务已完成)
+
+### ✅ 已完成功能
+
+1. **文件上传接口** ✅
+   - 支持 PDF、Word、Excel、图片上传
+   - 文件类型识别(MIME类型、扩展名、文件头)
+   - 文件验证和存储
+
+2. **PDF文本提取** ✅
+   - 分页判断逻辑实现
+   - 有文本层直接提取
+   - 无文本层使用OCR(页面转图片)
+   - 自动清理临时文件
+
+3. **OCR集成** ✅
+   - PaddleOCR客户端实现
+   - HTTP API调用
+   - 错误处理
+
+4. **TXT文件存储** ✅
+   - 文本内容写入SSD硬盘
+   - 路径管理
+
+5. **解析任务管理** ✅
+   - 异步任务队列
+   - 状态跟踪
+   - 进度查询
+
+---
+
+## ❌ 待完善功能
+
+### P0 - 高优先级(阻塞后续开发)
+
+#### 1. Word文档文本提取 ✅ **已完成**
+- **当前状态**:已实现Word文档直接文本提取
+- **实现内容**:
+  - ✅ 添加Apache POI依赖
+  - ✅ 创建WordTextExtractionService
+  - ✅ 支持.docx和.doc格式
+  - ✅ 提取纯文本内容
+- **文件位置**:`WordTextExtractionService.java`,已集成到`ParseService.java`
+- **优先级**:P0 ✅
+
+#### 2. OCR结果解析 ✅ **已完成**
+- **当前状态**:已实现通用的JSON解析器
+- **实现内容**:
+  - ✅ 创建OcrResultParser使用Jackson解析JSON
+  - ✅ 支持多种JSON格式(text字段、result数组、data对象等)
+  - ✅ 处理数组和嵌套对象
+  - ✅ 已集成到ParseService和PdfTextExtractionService
+- **文件位置**:`OcrResultParser.java`
+- **优先级**:P0 ✅
+
+#### 3. 文本存储路径记录 ✅ **已完成**
+- **当前状态**:已实现文本存储路径记录功能
+- **实现内容**:
+  - ✅ 创建GraphServiceClient(Feign Client)
+  - ✅ 在graph-service中实现TextStorageController和TextStorageService
+  - ✅ 记录text_storage表信息(document_id, file_path, file_size, checksum)
+  - ✅ 在ParseService中调用,自动计算MD5校验和
+- **文件位置**:
+  - `GraphServiceClient.java`(Feign Client)
+  - `graph-service/TextStorageController.java`
+  - `graph-service/TextStorageService.java`
+  - 已集成到`ParseService.java`
+- **优先级**:P0 ✅
+
+---
+
+### P1 - 中优先级(重要功能)
+
+#### 4. Excel表格提取 ✅ **已完成**
+- **当前状态**:已实现Excel表格直接文本提取
+- **实现内容**:
+  - ✅ 使用Apache POI(已添加依赖)
+  - ✅ 创建ExcelTextExtractionService
+  - ✅ 支持.xlsx和.xls格式
+  - ✅ 提取表格数据为文本格式(保留表格结构)
+  - ✅ 支持多工作表、公式、日期等数据类型
+- **文件位置**:`ExcelTextExtractionService.java`,已集成到`ParseService.java`
+- **优先级**:P1 ✅
+
+#### 5. 版面分析 ✅ **已完成**
+- **当前状态**:已实现基础版面分析功能
+- **实现内容**:
+  - ✅ 创建LayoutAnalysisService
+  - ✅ 识别文档结构(标题、段落、列表、表格行等)
+  - ✅ 提取位置信息(行索引、段落索引、位置坐标等)
+  - ✅ 支持PDF、Word、Excel等不同文件类型
+  - ✅ 分析结果保存到解析任务选项,供后续图节点构建使用
+- **文件位置**:`LayoutAnalysisService.java`,已集成到`ParseService.java`
+- **优先级**:P1 ✅
+- **注意**:当前为基础实现,后续可以使用更精确的版面分析库(如PDFBox的PDFLayoutTextStripper)进行增强
+
+---
+
+### P2 - 低优先级(优化功能)
+
+#### 6. 异步任务处理优化 ⚠️ **部分实现**
+- **当前状态**:使用线程池,但未使用消息队列
+- **需要优化**:
+  - 集成RabbitMQ(依赖已添加)
+  - 使用消息队列处理解析任务
+  - 支持任务重试机制
+  - 支持任务优先级
+- **文件位置**:
+  - `ParseTaskExecutor.java`(当前使用线程池)
+  - `ParseService.java:223`(TODO标记)
+- **优先级**:P2
+
+#### 7. 错误处理和重试机制 ⚠️ **基础实现**
+- **当前状态**:有基础错误处理,但缺少重试机制
+- **需要完善**:
+  - OCR调用失败重试
+  - 文件处理异常重试
+  - 更详细的错误日志
+  - 错误分类和处理策略
+- **优先级**:P2
+
+#### 8. 性能优化 ⚠️ **待优化**
+- **当前状态**:单线程处理,符合GPU约束
+- **需要优化**:
+  - 大文件分块处理
+  - 内存优化(PDF处理)
+  - 并发控制优化
+- **优先级**:P2
+
+---
+
+## 📝 代码中的TODO标记
+
+### ParseService.java
+1. ✅ ~~第128行~~:调用 graph-service 或 document-service 记录 text_storage 信息 - **已完成**
+2. ✅ ~~第218行~~:根据实际返回结构提取文字内容(OCR结果解析) - **已完成**
+3. ✅ ~~第222行~~:实现版面分析 - **已完成**
+4. **第223行**:实现异步任务处理(MQ / 线程池等) - **P2优先级**
+
+### PdfTextExtractionService.java
+1. ✅ ~~第185行~~:根据实际OCR服务返回的JSON结构解析 - **已完成**
+
+---
+
+## 🔧 需要添加的依赖
+
+### 已添加
+- ✅ Apache PDFBox 3.0.1
+- ✅ Apache POI 5.2.5(Word/Excel处理)
+  - ✅ poi(核心库)
+  - ✅ poi-ooxml(.docx, .xlsx)
+  - ✅ poi-scratchpad(.doc, .xls)
+
+---
+
+## 📊 功能覆盖情况
+
+| 文件类型 | 处理方式 | 状态 |
+|---------|---------|------|
+| PDF | 分页判断(文本层/OCR) | ✅ 已完成 |
+| Word (.docx) | 直接文本提取 | ✅ 已完成 |
+| Word (.doc) | 直接文本提取 | ✅ 已完成 |
+| Excel (.xlsx) | 直接表格提取 | ✅ 已完成 |
+| Excel (.xls) | 直接表格提取 | ✅ 已完成 |
+| 图片 (JPG/PNG/GIF) | OCR | ✅ 已完成 |
+
+---
+
+## 🎯 下一步行动计划
+
+### ✅ 已完成(P0)
+1. ✅ **Word文档文本提取**
+   - ✅ 添加Apache POI依赖
+   - ✅ 实现WordTextExtractionService
+   - ✅ 集成到ParseService
+
+2. ✅ **OCR结果解析完善**
+   - ✅ 创建OcrResultParser使用Jackson解析JSON
+   - ✅ 支持多种JSON格式
+   - ✅ 提取文本内容
+   - ✅ 集成到ParseService和PdfTextExtractionService
+
+3. ✅ **文本存储路径记录**
+   - ✅ 创建GraphServiceClient(Feign Client)
+   - ✅ 在graph-service中实现TextStorageController和TextStorageService
+   - ✅ 在ParseService中调用,自动计算MD5校验和
+
+### ✅ 已完成(P1)
+4. ✅ **Excel表格提取**
+   - ✅ 创建ExcelTextExtractionService
+   - ✅ 支持.xlsx和.xls格式
+   - ✅ 提取表格数据为文本格式
+   - ✅ 集成到ParseService
+
+5. ✅ **版面分析**
+   - ✅ 创建LayoutAnalysisService
+   - ✅ 识别文档结构(标题、段落、表格等)
+   - ✅ 提取位置信息
+   - ✅ 集成到ParseService,结果保存到任务选项
+
+### 后续完善(P2)
+6. ⚠️ 异步任务优化(MQ)(P2)
+7. ⚠️ 错误处理和重试机制(P2)
+8. ⚠️ 性能优化(P2)
+
+---
+
+## 📌 注意事项
+
+1. ✅ **OCR服务返回格式**:已实现通用的JSON解析器,支持多种常见格式
+2. ✅ **Feign Client**:已创建GraphServiceClient,graph-service已提供接口
+3. **性能考虑**:PDF处理可能消耗大量内存,大文件需要优化
+4. **GPU资源限制**:当前单线程处理符合GPU线性处理约束,优化时需注意
+5. **MD5校验和**:文本存储记录时会自动计算文件MD5,用于文件完整性校验
+
+---
+
+**最后更新**:2026-01-14
+
+---
+
+## ✅ 本次更新(2026-01-14 - P1任务完成)
+
+### 已完成的功能
+
+1. **Word文档文本提取** ✅
+   - 创建了`WordTextExtractionService`
+   - 支持.docx和.doc格式
+   - 已集成到ParseService
+
+2. **OCR结果解析** ✅
+   - 创建了`OcrResultParser`使用Jackson解析JSON
+   - 支持多种JSON格式(text字段、result数组、data对象等)
+   - 已集成到ParseService和PdfTextExtractionService
+
+3. **文本存储路径记录** ✅
+   - 创建了`GraphServiceClient`(Feign Client)
+   - 在graph-service中实现了`TextStorageController`和`TextStorageService`
+   - 自动计算MD5校验和
+   - 已集成到ParseService
+
+### 新增文件
+
+- `parse-service/src/main/java/com/lingyue/parse/service/WordTextExtractionService.java`
+- `parse-service/src/main/java/com/lingyue/parse/service/OcrResultParser.java`
+- `parse-service/src/main/java/com/lingyue/parse/client/GraphServiceClient.java`
+- `graph-service/src/main/java/com/lingyue/graph/service/TextStorageService.java`
+- `graph-service/src/main/java/com/lingyue/graph/controller/TextStorageController.java`
+- `parse-service/src/main/java/com/lingyue/parse/service/ExcelTextExtractionService.java`(P1)
+- `parse-service/src/main/java/com/lingyue/parse/service/LayoutAnalysisService.java`(P1)
+
+### 更新的文件
+
+- `backend/pom.xml` - 添加POI依赖版本管理
+- `parse-service/pom.xml` - 添加POI依赖
+- `parse-service/src/main/java/com/lingyue/parse/service/ParseService.java` - 集成所有新功能(Word提取、Excel提取、版面分析、文本存储记录)
+- `parse-service/src/main/java/com/lingyue/parse/service/PdfTextExtractionService.java` - 使用OcrResultParser

+ 20 - 0
backend/parse-service/pom.xml

@@ -66,6 +66,26 @@
             <artifactId>lombok</artifactId>
             <optional>true</optional>
         </dependency>
+        
+        <!-- Apache PDFBox -->
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+        </dependency>
+        
+        <!-- Apache POI (Word/Excel处理) -->
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-scratchpad</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 1 - 1
backend/parse-service/src/main/java/com/lingyue/parse/ParseServiceApplication.java

@@ -10,7 +10,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
  */
 @SpringBootApplication
 @EnableDiscoveryClient
-@EnableFeignClients
+@EnableFeignClients(basePackages = "com.lingyue.parse.client")
 public class ParseServiceApplication {
     
     public static void main(String[] args) {

+ 28 - 0
backend/parse-service/src/main/java/com/lingyue/parse/client/GraphServiceClient.java

@@ -0,0 +1,28 @@
+package com.lingyue.parse.client;
+
+import com.lingyue.common.domain.AjaxResult;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.util.Map;
+
+/**
+ * Graph Service Feign Client
+ * 用于调用graph-service的接口
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@FeignClient(name = "graph-service", path = "/api/v1/graph")
+public interface GraphServiceClient {
+    
+    /**
+     * 记录文本存储路径
+     * 
+     * @param request 文本存储信息
+     * @return 操作结果
+     */
+    @PostMapping("/text-storage")
+    AjaxResult<?> saveTextStorage(@RequestBody Map<String, Object> request);
+}

+ 193 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/ExcelTextExtractionService.java

@@ -0,0 +1,193 @@
+package com.lingyue.parse.service;
+
+import com.lingyue.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.hssf.usermodel.HSSFWorkbook;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+/**
+ * Excel表格文本提取服务
+ * 支持.xlsx和.xls格式
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Service
+public class ExcelTextExtractionService {
+    
+    /**
+     * 提取Excel表格文本
+     * 
+     * @param excelFilePath Excel文件路径
+     * @return 提取的文本内容(表格格式)
+     */
+    public String extractText(String excelFilePath) {
+        File excelFile = new File(excelFilePath);
+        if (!excelFile.exists()) {
+            throw new ServiceException("Excel文件不存在: " + excelFilePath);
+        }
+        
+        String fileName = excelFile.getName().toLowerCase();
+        
+        try {
+            if (fileName.endsWith(".xlsx")) {
+                return extractFromXlsx(excelFilePath);
+            } else if (fileName.endsWith(".xls")) {
+                return extractFromXls(excelFilePath);
+            } else {
+                throw new ServiceException("不支持的Excel文件格式: " + fileName);
+            }
+        } catch (IOException e) {
+            log.error("提取Excel表格文本失败: {}", excelFilePath, e);
+            throw new ServiceException("提取Excel表格文本失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 从.xlsx文件提取文本
+     */
+    private String extractFromXlsx(String filePath) throws IOException {
+        log.info("提取.xlsx文件文本: {}", filePath);
+        
+        StringBuilder text = new StringBuilder();
+        
+        try (FileInputStream fis = new FileInputStream(filePath);
+             Workbook workbook = new XSSFWorkbook(fis)) {
+            
+            int sheetCount = workbook.getNumberOfSheets();
+            log.debug("Excel文件包含 {} 个工作表", sheetCount);
+            
+            for (int sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) {
+                Sheet sheet = workbook.getSheetAt(sheetIndex);
+                String sheetName = sheet.getSheetName();
+                
+                text.append("=== 工作表: ").append(sheetName).append(" ===\n");
+                
+                // 遍历所有行
+                for (Row row : sheet) {
+                    StringBuilder rowText = new StringBuilder();
+                    
+                    // 遍历所有单元格
+                    for (Cell cell : row) {
+                        String cellValue = getCellValueAsString(cell);
+                        rowText.append(cellValue).append("\t");
+                    }
+                    
+                    if (rowText.length() > 0) {
+                        text.append(rowText.toString().trim()).append("\n");
+                    }
+                }
+                
+                text.append("\n");
+            }
+        }
+        
+        String result = text.toString();
+        log.debug("提取到文本长度: {}", result.length());
+        return result;
+    }
+    
+    /**
+     * 从.xls文件提取文本
+     */
+    private String extractFromXls(String filePath) throws IOException {
+        log.info("提取.xls文件文本: {}", filePath);
+        
+        StringBuilder text = new StringBuilder();
+        
+        try (FileInputStream fis = new FileInputStream(filePath);
+             Workbook workbook = new HSSFWorkbook(fis)) {
+            
+            int sheetCount = workbook.getNumberOfSheets();
+            log.debug("Excel文件包含 {} 个工作表", sheetCount);
+            
+            for (int sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) {
+                Sheet sheet = workbook.getSheetAt(sheetIndex);
+                String sheetName = sheet.getSheetName();
+                
+                text.append("=== 工作表: ").append(sheetName).append(" ===\n");
+                
+                // 遍历所有行
+                for (Row row : sheet) {
+                    StringBuilder rowText = new StringBuilder();
+                    
+                    // 遍历所有单元格
+                    for (Cell cell : row) {
+                        String cellValue = getCellValueAsString(cell);
+                        rowText.append(cellValue).append("\t");
+                    }
+                    
+                    if (rowText.length() > 0) {
+                        text.append(rowText.toString().trim()).append("\n");
+                    }
+                }
+                
+                text.append("\n");
+            }
+        }
+        
+        String result = text.toString();
+        log.debug("提取到文本长度: {}", result.length());
+        return result;
+    }
+    
+    /**
+     * 获取单元格值(字符串格式)
+     */
+    private String getCellValueAsString(Cell cell) {
+        if (cell == null) {
+            return "";
+        }
+        
+        switch (cell.getCellType()) {
+            case STRING:
+                return cell.getStringCellValue();
+            case NUMERIC:
+                if (DateUtil.isCellDateFormatted(cell)) {
+                    // 日期格式
+                    return cell.getDateCellValue().toString();
+                } else {
+                    // 数字格式,避免科学计数法
+                    double numericValue = cell.getNumericCellValue();
+                    if (numericValue == (long) numericValue) {
+                        return String.valueOf((long) numericValue);
+                    } else {
+                        return String.valueOf(numericValue);
+                    }
+                }
+            case BOOLEAN:
+                return String.valueOf(cell.getBooleanCellValue());
+            case FORMULA:
+                // 公式单元格,获取计算结果
+                try {
+                    if (cell.getCachedFormulaResultType() == CellType.NUMERIC) {
+                        double numericValue = cell.getNumericCellValue();
+                        if (numericValue == (long) numericValue) {
+                            return String.valueOf((long) numericValue);
+                        } else {
+                            return String.valueOf(numericValue);
+                        }
+                    } else if (cell.getCachedFormulaResultType() == CellType.STRING) {
+                        return cell.getStringCellValue();
+                    } else if (cell.getCachedFormulaResultType() == CellType.BOOLEAN) {
+                        return String.valueOf(cell.getBooleanCellValue());
+                    }
+                } catch (Exception e) {
+                    log.debug("获取公式计算结果失败,返回公式字符串", e);
+                    return cell.getCellFormula();
+                }
+                return cell.getCellFormula();
+            case BLANK:
+                return "";
+            default:
+                return "";
+        }
+    }
+}

+ 1 - 1
backend/parse-service/src/main/java/com/lingyue/parse/service/FileUploadService.java

@@ -65,7 +65,7 @@ public class FileUploadService {
         }
 
         // 6. 提交解析任务(异步执行)
-        parseTaskExecutor.submitParseTask(documentId, filePath);
+        parseTaskExecutor.submitParseTask(documentId, filePath, fileType);
         
         // 7. 构建响应
         return FileUploadResponse.builder()

+ 330 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/LayoutAnalysisService.java

@@ -0,0 +1,330 @@
+package com.lingyue.parse.service;
+
+import com.lingyue.parse.enums.FileType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 版面分析服务
+ * 识别文档结构(标题、段落、表格、图片等)并提取位置信息
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LayoutAnalysisService {
+    
+    /**
+     * 分析文档版面结构
+     * 
+     * @param filePath 文件路径
+     * @param fileType 文件类型
+     * @param textContent 已提取的文本内容
+     * @return 版面分析结果
+     */
+    public LayoutAnalysisResult analyzeLayout(String filePath, FileType fileType, String textContent) {
+        log.info("开始版面分析: filePath={}, fileType={}", filePath, fileType);
+        
+        LayoutAnalysisResult result = new LayoutAnalysisResult();
+        result.setFilePath(filePath);
+        result.setFileType(fileType.name());
+        
+        List<LayoutElement> elements = new ArrayList<>();
+        
+        if (fileType == FileType.PDF) {
+            elements = analyzePdfLayout(filePath, textContent);
+        } else if (fileType == FileType.WORD || fileType == FileType.WORD_OLD) {
+            elements = analyzeWordLayout(filePath, textContent);
+        } else if (fileType == FileType.EXCEL || fileType == FileType.EXCEL_OLD) {
+            elements = analyzeExcelLayout(filePath, textContent);
+        } else {
+            // 其他文件类型使用简单分析
+            elements = analyzeSimpleLayout(textContent);
+        }
+        
+        result.setElements(elements);
+        result.setElementCount(elements.size());
+        
+        log.info("版面分析完成: 识别到 {} 个元素", elements.size());
+        return result;
+    }
+    
+    /**
+     * 分析PDF版面结构
+     */
+    private List<LayoutElement> analyzePdfLayout(String filePath, String textContent) {
+        List<LayoutElement> elements = new ArrayList<>();
+        
+        // TODO: 使用PDFBox的版面分析功能
+        // 当前实现:基于文本内容进行简单分析
+        // 后续可以使用PDFBox的PDFTextStripperByArea或PDFLayoutTextStripper进行更精确的分析
+        
+        String[] lines = textContent.split("\n");
+        int lineIndex = 0;
+        
+        for (String line : lines) {
+            if (line.trim().isEmpty()) {
+                continue;
+            }
+            
+            LayoutElement element = new LayoutElement();
+            element.setType(detectElementType(line));
+            element.setContent(line.trim());
+            element.setLineIndex(lineIndex);
+            element.setPosition(createPosition("line", lineIndex, 0, line.length()));
+            
+            elements.add(element);
+            lineIndex++;
+        }
+        
+        return elements;
+    }
+    
+    /**
+     * 分析Word版面结构
+     */
+    private List<LayoutElement> analyzeWordLayout(String filePath, String textContent) {
+        List<LayoutElement> elements = new ArrayList<>();
+        
+        // TODO: 使用POI提取Word的结构信息(标题、段落等)
+        // 当前实现:基于文本内容进行简单分析
+        
+        String[] paragraphs = textContent.split("\n\n");
+        int paragraphIndex = 0;
+        
+        for (String paragraph : paragraphs) {
+            if (paragraph.trim().isEmpty()) {
+                continue;
+            }
+            
+            LayoutElement element = new LayoutElement();
+            element.setType(detectElementType(paragraph));
+            element.setContent(paragraph.trim());
+            element.setParagraphIndex(paragraphIndex);
+            element.setPosition(createPosition("paragraph", paragraphIndex, 0, paragraph.length()));
+            
+            elements.add(element);
+            paragraphIndex++;
+        }
+        
+        return elements;
+    }
+    
+    /**
+     * 分析Excel版面结构
+     */
+    private List<LayoutElement> analyzeExcelLayout(String filePath, String textContent) {
+        List<LayoutElement> elements = new ArrayList<>();
+        
+        // Excel主要是表格数据
+        String[] lines = textContent.split("\n");
+        int lineIndex = 0;
+        
+        for (String line : lines) {
+            if (line.trim().isEmpty() || line.startsWith("===")) {
+                // 跳过空行和工作表标题
+                continue;
+            }
+            
+            LayoutElement element = new LayoutElement();
+            element.setType("table_row");
+            element.setContent(line.trim());
+            element.setLineIndex(lineIndex);
+            element.setPosition(createPosition("row", lineIndex, 0, line.length()));
+            
+            // 解析表格单元格
+            String[] cells = line.split("\t");
+            List<Map<String, Object>> cellData = new ArrayList<>();
+            for (int i = 0; i < cells.length; i++) {
+                Map<String, Object> cell = new HashMap<>();
+                cell.put("column", i);
+                cell.put("value", cells[i].trim());
+                cellData.add(cell);
+            }
+            Map<String, Object> metadata = new HashMap<>();
+            metadata.put("cells", cellData);
+            element.setMetadata(metadata);
+            
+            elements.add(element);
+            lineIndex++;
+        }
+        
+        return elements;
+    }
+    
+    /**
+     * 简单版面分析(用于图片等)
+     */
+    private List<LayoutElement> analyzeSimpleLayout(String textContent) {
+        List<LayoutElement> elements = new ArrayList<>();
+        
+        if (textContent == null || textContent.trim().isEmpty()) {
+            return elements;
+        }
+        
+        LayoutElement element = new LayoutElement();
+        element.setType("text");
+        element.setContent(textContent.trim());
+        element.setPosition(createPosition("text", 0, 0, textContent.length()));
+        
+        elements.add(element);
+        return elements;
+    }
+    
+    /**
+     * 检测元素类型(基于内容特征)
+     */
+    private String detectElementType(String content) {
+        if (content == null || content.trim().isEmpty()) {
+            return "empty";
+        }
+        
+        String trimmed = content.trim();
+        
+        // 检测标题(通常较短,可能是全大写,或包含特定格式)
+        if (trimmed.length() < 100 && 
+            (trimmed.toUpperCase().equals(trimmed) || 
+             trimmed.matches("^[一二三四五六七八九十]+[、.].*") ||
+             trimmed.matches("^第[一二三四五六七八九十]+[章节].*"))) {
+            return "heading";
+        }
+        
+        // 检测列表项
+        if (trimmed.matches("^[•·\\-\\*]\\s+.*") || 
+            trimmed.matches("^\\d+[.、]\\s+.*")) {
+            return "list_item";
+        }
+        
+        // 检测表格行(包含制表符)
+        if (trimmed.contains("\t")) {
+            return "table_row";
+        }
+        
+        // 默认段落
+        return "paragraph";
+    }
+    
+    /**
+     * 创建位置信息
+     */
+    private Map<String, Object> createPosition(String type, int index, int start, int length) {
+        Map<String, Object> position = new HashMap<>();
+        position.put("type", type);
+        position.put("index", index);
+        position.put("start", start);
+        position.put("length", length);
+        return position;
+    }
+    
+    /**
+     * 版面分析结果
+     */
+    public static class LayoutAnalysisResult {
+        private String filePath;
+        private String fileType;
+        private List<LayoutElement> elements;
+        private int elementCount;
+        
+        // Getters and Setters
+        public String getFilePath() {
+            return filePath;
+        }
+        
+        public void setFilePath(String filePath) {
+            this.filePath = filePath;
+        }
+        
+        public String getFileType() {
+            return fileType;
+        }
+        
+        public void setFileType(String fileType) {
+            this.fileType = fileType;
+        }
+        
+        public List<LayoutElement> getElements() {
+            return elements;
+        }
+        
+        public void setElements(List<LayoutElement> elements) {
+            this.elements = elements;
+        }
+        
+        public int getElementCount() {
+            return elementCount;
+        }
+        
+        public void setElementCount(int elementCount) {
+            this.elementCount = elementCount;
+        }
+    }
+    
+    /**
+     * 版面元素
+     */
+    public static class LayoutElement {
+        private String type; // heading/paragraph/list_item/table_row/text/image
+        private String content;
+        private Integer lineIndex;
+        private Integer paragraphIndex;
+        private Map<String, Object> position; // {type, index, start, length, page, x, y, width, height}
+        private Map<String, Object> metadata; // 额外元数据
+        
+        // Getters and Setters
+        public String getType() {
+            return type;
+        }
+        
+        public void setType(String type) {
+            this.type = type;
+        }
+        
+        public String getContent() {
+            return content;
+        }
+        
+        public void setContent(String content) {
+            this.content = content;
+        }
+        
+        public Integer getLineIndex() {
+            return lineIndex;
+        }
+        
+        public void setLineIndex(Integer lineIndex) {
+            this.lineIndex = lineIndex;
+        }
+        
+        public Integer getParagraphIndex() {
+            return paragraphIndex;
+        }
+        
+        public void setParagraphIndex(Integer paragraphIndex) {
+            this.paragraphIndex = paragraphIndex;
+        }
+        
+        public Map<String, Object> getPosition() {
+            return position;
+        }
+        
+        public void setPosition(Map<String, Object> position) {
+            this.position = position;
+        }
+        
+        public Map<String, Object> getMetadata() {
+            return metadata;
+        }
+        
+        public void setMetadata(Map<String, Object> metadata) {
+            this.metadata = metadata;
+        }
+    }
+}

+ 130 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/OcrResultParser.java

@@ -0,0 +1,130 @@
+package com.lingyue.parse.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * OCR结果解析器
+ * 根据OCR服务返回的JSON结构解析文本内容
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Component
+public class OcrResultParser {
+    
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    
+    /**
+     * 解析OCR结果,提取文本内容
+     * 
+     * 支持的JSON格式:
+     * 1. {"text": "识别的文本内容"}
+     * 2. {"result": [{"text": "文本1"}, {"text": "文本2"}]}
+     * 3. [{"text": "文本1", "confidence": 0.95}, {"text": "文本2", "confidence": 0.90}]
+     * 4. {"data": {"text": "文本内容"}}
+     * 
+     * @param ocrResult OCR原始结果(JSON字符串)
+     * @return 提取的文本内容
+     */
+    public String parseText(String ocrResult) {
+        if (ocrResult == null || ocrResult.trim().isEmpty()) {
+            return "";
+        }
+        
+        try {
+            JsonNode rootNode = objectMapper.readTree(ocrResult);
+            return extractTextFromJson(rootNode);
+        } catch (Exception e) {
+            log.debug("OCR结果不是有效的JSON,返回原始结果", e);
+            // 如果不是JSON格式,直接返回原始结果
+            return ocrResult;
+        }
+    }
+    
+    /**
+     * 从JSON节点中提取文本
+     */
+    private String extractTextFromJson(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return "";
+        }
+        
+        // 如果是数组,遍历提取
+        if (node.isArray()) {
+            List<String> texts = new ArrayList<>();
+            for (JsonNode item : node) {
+                String text = extractTextFromJson(item);
+                if (text != null && !text.trim().isEmpty()) {
+                    texts.add(text);
+                }
+            }
+            return String.join("\n", texts);
+        }
+        
+        // 如果是对象,查找text字段
+        if (node.isObject()) {
+            // 直接查找text字段
+            if (node.has("text")) {
+                JsonNode textNode = node.get("text");
+                if (textNode.isTextual()) {
+                    return textNode.asText();
+                }
+            }
+            
+            // 查找result字段(可能是数组)
+            if (node.has("result")) {
+                JsonNode resultNode = node.get("result");
+                return extractTextFromJson(resultNode);
+            }
+            
+            // 查找data字段
+            if (node.has("data")) {
+                JsonNode dataNode = node.get("data");
+                return extractTextFromJson(dataNode);
+            }
+            
+            // 查找content字段
+            if (node.has("content")) {
+                JsonNode contentNode = node.get("content");
+                if (contentNode.isTextual()) {
+                    return contentNode.asText();
+                }
+            }
+            
+            // 如果都没有,尝试提取所有文本字段
+            List<String> texts = new ArrayList<>();
+            node.fields().forEachRemaining(entry -> {
+                JsonNode value = entry.getValue();
+                if (value.isTextual()) {
+                    String text = value.asText();
+                    if (text != null && !text.trim().isEmpty()) {
+                        texts.add(text);
+                    }
+                } else if (value.isArray() || value.isObject()) {
+                    String text = extractTextFromJson(value);
+                    if (text != null && !text.trim().isEmpty()) {
+                        texts.add(text);
+                    }
+                }
+            });
+            
+            if (!texts.isEmpty()) {
+                return String.join("\n", texts);
+            }
+        }
+        
+        // 如果是文本节点,直接返回
+        if (node.isTextual()) {
+            return node.asText();
+        }
+        
+        return "";
+    }
+}

+ 161 - 23
backend/parse-service/src/main/java/com/lingyue/parse/service/ParseService.java

@@ -2,11 +2,13 @@ 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 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;
@@ -24,7 +26,13 @@ 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;
+    private final com.lingyue.parse.client.GraphServiceClient graphServiceClient;
 
     /**
      * 根据ID获取解析任务
@@ -57,49 +65,128 @@ public class ParseService {
     }
 
     /**
-     * 对指定文档执行 OCR 并将结果写入 TXT 文件
-     *
-     * 当前实现为同步调用,后续可以改为异步任务。
+     * 对指定文档执行解析并将结果写入 TXT 文件
+     * 根据文件类型选择不同的处理方式:
+     * - PDF: 使用分页判断逻辑(有文本层直接提取,无文本层使用OCR)
+     * - 图片: 使用OCR
+     * - 其他: 使用OCR
      *
      * @param documentId 文档ID
      * @param sourceFilePath 原始文件路径
+     * @param fileType 文件类型
      * @return 更新后的解析任务
      */
-    public ParseTask runOcrAndSaveText(String documentId, String sourceFilePath) {
+    public ParseTask parseAndSaveText(String documentId, String sourceFilePath, FileType fileType) {
         // 1. 初始化或更新解析任务
         ParseTask task = getOrCreateTask(documentId);
         task.setStatus("processing");
-        task.setCurrentStep("ocr");
+        task.setCurrentStep("parsing");
         task.setProgress(10);
         saveParseTask(task);
 
         try {
-            // 2. 调用 OCR 服务
-            String ocrResult = paddleOcrClient.ocrFile(sourceFilePath);
-
-            // TODO: 根据实际返回结构,将 ocrResult 解析为纯文本
-            String plainText = extractPlainTextFromOcrResult(ocrResult);
+            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);
+            } 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);
+            } 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);
+            } 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("写入 OCR 文本到 TXT 文件失败, path={}", textFilePath, ioException);
-                throw new RuntimeException("写入 OCR 文本失败: " + ioException.getMessage(), 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<String, Object> options = (java.util.Map<String, Object>) task.getOptions();
+                options.put("layoutAnalysis", layoutResult);
+            } catch (Exception e) {
+                log.warn("版面分析失败,但不影响主流程: documentId={}", documentId, e);
+                // 版面分析失败不影响主流程,只记录警告日志
             }
-            log.info("OCR 文本已写入: {}", textFilePath);
 
-            // TODO: 在此处调用 graph-service 或 document-service 记录 text_storage 信息
+            // 5. 记录文本存储路径到数据库
+            task.setCurrentStep("recording");
+            task.setProgress(90);
+            saveParseTask(task);
+            
+            try {
+                recordTextStorage(documentId, textFilePath);
+            } catch (Exception e) {
+                log.warn("记录文本存储路径失败,但不影响主流程: documentId={}, filePath={}", documentId, textFilePath, e);
+                // 记录失败不影响主流程,只记录警告日志
+            }
 
-            // 4. 更新任务状态为完成
+            // 6. 更新任务状态为完成
             task.setStatus("completed");
             task.setCurrentStep("completed");
             task.setProgress(100);
             task.setCompletedAt(new java.util.Date());
             saveParseTask(task);
         } catch (Exception e) {
-            log.error("执行 OCR 解析任务失败, documentId={}", documentId, e);
+            log.error("执行解析任务失败, documentId={}", documentId, e);
             task.setStatus("failed");
             task.setCurrentStep("failed");
             task.setErrorMessage(e.getMessage());
@@ -109,6 +196,33 @@ public class ParseService {
 
         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);
+    }
 
     /**
      * 获取或创建解析任务
@@ -147,17 +261,41 @@ public class ParseService {
     }
 
     /**
-     * 从 OCR 返回结果中提取纯文本
-     *
-     * 这里先做一个简单占位实现:直接返回原始 JSON,
-     * 后续可以根据你本地 GPU 服务的返回结构进行 JSON 解析与拼接。
+     * 从 OCR 返回结果中提取纯文本(兼容旧接口)
+     * 
+     * @deprecated 使用 OcrResultParser.parseText() 替代
      */
+    @Deprecated
     private String extractPlainTextFromOcrResult(String ocrResult) {
-        // TODO: 根据实际返回结构提取文字内容
-        return ocrResult == null ? "" : ocrResult;
+        return ocrResultParser.parseText(ocrResult);
+    }
+
+    /**
+     * 记录文本存储路径到数据库
+     * 
+     * @param documentId 文档ID
+     * @param textFilePath 文本文件路径
+     */
+    private void recordTextStorage(String documentId, String textFilePath) {
+        try {
+            java.util.Map<String, Object> request = new java.util.HashMap<>();
+            request.put("documentId", documentId);
+            request.put("filePath", textFilePath);
+            
+            com.lingyue.common.domain.AjaxResult<?> result = graphServiceClient.saveTextStorage(request);
+            
+            if (result != null && result.getCode() == 200) {
+                log.info("文本存储路径记录成功: documentId={}, filePath={}", documentId, textFilePath);
+            } else {
+                log.warn("文本存储路径记录失败: documentId={}, filePath={}, result={}", 
+                        documentId, textFilePath, result);
+            }
+        } catch (Exception e) {
+            log.error("调用graph-service记录文本存储路径异常", e);
+            throw e;
+        }
     }
 
-    // TODO: 实现版面分析
     // TODO: 实现异步任务处理(MQ / 线程池等)
 }
 

+ 17 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/ParseTaskExecutor.java

@@ -33,6 +33,23 @@ public class ParseTaskExecutor {
      *
      * @param documentId 文档ID
      * @param sourceFilePath 原始文件路径
+     * @param fileType 文件类型
+     */
+    public void submitParseTask(String documentId, String sourceFilePath, com.lingyue.parse.enums.FileType fileType) {
+        executor.submit(() -> {
+            try {
+                parseService.parseAndSaveText(documentId, sourceFilePath, fileType);
+            } catch (Exception e) {
+                log.error("解析任务执行失败, documentId={}", documentId, e);
+            }
+        });
+    }
+    
+    /**
+     * 提交解析任务(异步执行,兼容旧接口)
+     *
+     * @param documentId 文档ID
+     * @param sourceFilePath 原始文件路径
      */
     public void submitParseTask(String documentId, String sourceFilePath) {
         executor.submit(() -> {

+ 236 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/PdfTextExtractionService.java

@@ -0,0 +1,236 @@
+package com.lingyue.parse.service;
+
+import com.lingyue.common.exception.ServiceException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * PDF文本提取服务
+ * 实现分页判断逻辑:如果有文本层(文本不少)直接用,如果没有就用OCR
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PdfTextExtractionService {
+    
+    private final PaddleOcrClient paddleOcrClient;
+    private final OcrResultParser ocrResultParser;
+    
+    /**
+     * 文本阈值:每页至少需要这么多字符才认为有文本层
+     */
+    private static final int TEXT_THRESHOLD = 50;
+    
+    /**
+     * 提取PDF文本(分页判断)
+     * 
+     * @param pdfFilePath PDF文件路径
+     * @return 提取的文本内容
+     */
+    public String extractText(String pdfFilePath) {
+        File pdfFile = new File(pdfFilePath);
+        if (!pdfFile.exists()) {
+            throw new ServiceException("PDF文件不存在: " + pdfFilePath);
+        }
+        
+        List<PageTextResult> pageResults = new ArrayList<>();
+        
+        try (PDDocument document = PDDocument.load(pdfFile)) {
+            int totalPages = document.getNumberOfPages();
+            log.info("开始处理PDF文件: {}, 总页数: {}", pdfFilePath, totalPages);
+            
+            PDFTextStripper textStripper = new PDFTextStripper();
+            
+            // 逐页处理
+            for (int pageNum = 1; pageNum <= totalPages; pageNum++) {
+                log.debug("处理第 {} 页/共 {} 页", pageNum, totalPages);
+                
+                try {
+                    // 提取当前页的文本
+                    textStripper.setStartPage(pageNum);
+                    textStripper.setEndPage(pageNum);
+                    String pageText = textStripper.getText(document);
+                    
+                    // 判断文本层是否足够
+                    if (hasSufficientText(pageText)) {
+                        // 有文本层,直接使用
+                        log.debug("第 {} 页有文本层,直接使用,文本长度: {}", pageNum, pageText.length());
+                        pageResults.add(new PageTextResult(pageNum, pageText, false));
+                    } else {
+                        // 没有文本层或文本很少,使用OCR
+                        log.debug("第 {} 页文本不足,使用OCR处理,文本长度: {}", pageNum, pageText.length());
+                        String ocrText = extractTextByOcr(pdfFilePath, pageNum);
+                        pageResults.add(new PageTextResult(pageNum, ocrText, true));
+                    }
+                } catch (Exception e) {
+                    log.error("处理第 {} 页时出错,尝试使用OCR", pageNum, e);
+                    // 出错时也尝试OCR
+                    try {
+                        String ocrText = extractTextByOcr(pdfFilePath, pageNum);
+                        pageResults.add(new PageTextResult(pageNum, ocrText, true));
+                    } catch (Exception ocrException) {
+                        log.error("第 {} 页OCR也失败", pageNum, ocrException);
+                        pageResults.add(new PageTextResult(pageNum, "", true));
+                    }
+                }
+            }
+        } catch (IOException e) {
+            log.error("读取PDF文件失败: {}", pdfFilePath, e);
+            throw new ServiceException("读取PDF文件失败: " + e.getMessage());
+        }
+        
+        // 合并所有页面的文本
+        return combinePageTexts(pageResults);
+    }
+    
+    /**
+     * 判断文本是否足够(是否有文本层)
+     * 
+     * @param text 提取的文本
+     * @return true表示有足够的文本层,false表示需要OCR
+     */
+    private boolean hasSufficientText(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            return false;
+        }
+        
+        // 去除空白字符后计算有效字符数
+        String trimmedText = text.trim().replaceAll("\\s+", " ");
+        
+        // 如果有效字符数少于阈值,认为没有文本层
+        return trimmedText.length() >= TEXT_THRESHOLD;
+    }
+    
+    /**
+     * 使用OCR提取指定页面的文本
+     * 将PDF页面转换为图片,然后调用OCR服务
+     * 
+     * @param pdfFilePath PDF文件路径
+     * @param pageNum 页码(从1开始,PDFBox中从0开始)
+     * @return OCR识别的文本
+     */
+    private String extractTextByOcr(String pdfFilePath, int pageNum) {
+        log.info("使用OCR处理PDF第 {} 页: {}", pageNum, pdfFilePath);
+        
+        File pdfFile = new File(pdfFilePath);
+        File tempImageFile = null;
+        
+        try (PDDocument document = PDDocument.load(pdfFile)) {
+            // 1. 将PDF页面转换为图片
+            PDFRenderer pdfRenderer = new PDFRenderer(document);
+            BufferedImage image = pdfRenderer.renderImageWithDPI(pageNum - 1, 300); // 300 DPI
+            
+            // 2. 保存为临时图片文件
+            String tempDir = System.getProperty("java.io.tmpdir");
+            String imageFileName = "pdf_page_" + UUID.randomUUID() + "_" + pageNum + ".png";
+            tempImageFile = new File(tempDir, imageFileName);
+            ImageIO.write(image, "png", tempImageFile);
+            log.debug("PDF第 {} 页已转换为图片: {}", pageNum, tempImageFile.getAbsolutePath());
+            
+            // 3. 调用OCR服务识别图片
+            String ocrResult = paddleOcrClient.ocrFile(tempImageFile.getAbsolutePath());
+            
+            // 4. 解析OCR结果
+            return parseOcrResult(ocrResult, pageNum);
+        } catch (IOException e) {
+            log.error("将PDF第 {} 页转换为图片失败", pageNum, e);
+            throw new ServiceException("PDF页面转图片失败: " + e.getMessage());
+        } catch (Exception e) {
+            log.error("OCR处理PDF第 {} 页失败", pageNum, e);
+            throw new ServiceException("OCR处理失败: " + e.getMessage());
+        } finally {
+            // 5. 清理临时图片文件
+            if (tempImageFile != null && tempImageFile.exists()) {
+                try {
+                    Files.delete(tempImageFile.toPath());
+                    log.debug("已删除临时图片文件: {}", tempImageFile.getAbsolutePath());
+                } catch (IOException e) {
+                    log.warn("删除临时图片文件失败: {}", tempImageFile.getAbsolutePath(), e);
+                }
+            }
+        }
+    }
+    
+    /**
+     * 解析OCR结果
+     * 使用OcrResultParser解析OCR返回的JSON
+     * 
+     * @param ocrResult OCR原始结果(通常是JSON格式)
+     * @param pageNum 页码
+     * @return 解析后的文本
+     */
+    private String parseOcrResult(String ocrResult, int pageNum) {
+        return ocrResultParser.parseText(ocrResult);
+    }
+    
+    /**
+     * 合并所有页面的文本
+     * 
+     * @param pageResults 页面结果列表
+     * @return 合并后的文本
+     */
+    private String combinePageTexts(List<PageTextResult> pageResults) {
+        StringBuilder combinedText = new StringBuilder();
+        
+        for (PageTextResult result : pageResults) {
+            if (result.getText() != null && !result.getText().trim().isEmpty()) {
+                combinedText.append("=== 第 ").append(result.getPageNum()).append(" 页");
+                if (result.isOcrUsed()) {
+                    combinedText.append(" (OCR识别)");
+                } else {
+                    combinedText.append(" (文本层提取)");
+                }
+                combinedText.append(" ===\n");
+                combinedText.append(result.getText()).append("\n\n");
+            }
+        }
+        
+        return combinedText.toString();
+    }
+    
+    /**
+     * 页面文本结果
+     */
+    private static class PageTextResult {
+        private final int pageNum;
+        private final String text;
+        private final boolean ocrUsed;
+        
+        public PageTextResult(int pageNum, String text, boolean ocrUsed) {
+            this.pageNum = pageNum;
+            this.text = text;
+            this.ocrUsed = ocrUsed;
+        }
+        
+        public int getPageNum() {
+            return pageNum;
+        }
+        
+        public String getText() {
+            return text;
+        }
+        
+        public boolean isOcrUsed() {
+            return ocrUsed;
+        }
+    }
+}

+ 85 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/WordTextExtractionService.java

@@ -0,0 +1,85 @@
+package com.lingyue.parse.service;
+
+import com.lingyue.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.hwpf.HWPFDocument;
+import org.apache.poi.hwpf.extractor.WordExtractor;
+import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+/**
+ * Word文档文本提取服务
+ * 支持.docx和.doc格式
+ * 
+ * @author lingyue
+ * @since 2026-01-14
+ */
+@Slf4j
+@Service
+public class WordTextExtractionService {
+    
+    /**
+     * 提取Word文档文本
+     * 
+     * @param wordFilePath Word文件路径
+     * @return 提取的文本内容
+     */
+    public String extractText(String wordFilePath) {
+        File wordFile = new File(wordFilePath);
+        if (!wordFile.exists()) {
+            throw new ServiceException("Word文件不存在: " + wordFilePath);
+        }
+        
+        String fileName = wordFile.getName().toLowerCase();
+        
+        try {
+            if (fileName.endsWith(".docx")) {
+                return extractFromDocx(wordFilePath);
+            } else if (fileName.endsWith(".doc")) {
+                return extractFromDoc(wordFilePath);
+            } else {
+                throw new ServiceException("不支持的Word文件格式: " + fileName);
+            }
+        } catch (IOException e) {
+            log.error("提取Word文档文本失败: {}", wordFilePath, e);
+            throw new ServiceException("提取Word文档文本失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 从.docx文件提取文本
+     */
+    private String extractFromDocx(String filePath) throws IOException {
+        log.info("提取.docx文件文本: {}", filePath);
+        
+        try (FileInputStream fis = new FileInputStream(filePath);
+             XWPFDocument document = new XWPFDocument(fis);
+             XWPFWordExtractor extractor = new XWPFWordExtractor(document)) {
+            
+            String text = extractor.getText();
+            log.debug("提取到文本长度: {}", text != null ? text.length() : 0);
+            return text != null ? text : "";
+        }
+    }
+    
+    /**
+     * 从.doc文件提取文本
+     */
+    private String extractFromDoc(String filePath) throws IOException {
+        log.info("提取.doc文件文本: {}", filePath);
+        
+        try (FileInputStream fis = new FileInputStream(filePath);
+             HWPFDocument document = new HWPFDocument(fis);
+             WordExtractor extractor = new WordExtractor(document)) {
+            
+            String text = extractor.getText();
+            log.debug("提取到文本长度: {}", text != null ? text.length() : 0);
+            return text != null ? text : "";
+        }
+    }
+}

+ 26 - 0
backend/pom.xml

@@ -51,6 +51,8 @@
         <hutool.version>5.8.35</hutool.version>
         <fastjson.version>2.0.53</fastjson.version>
         <springdoc.version>2.7.0</springdoc.version>
+        <pdfbox.version>3.0.1</pdfbox.version>
+        <poi.version>5.2.5</poi.version>
     </properties>
 
     <dependencyManagement>
@@ -169,6 +171,30 @@
                 <artifactId>common</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            
+            <!-- Apache PDFBox -->
+            <dependency>
+                <groupId>org.apache.pdfbox</groupId>
+                <artifactId>pdfbox</artifactId>
+                <version>${pdfbox.version}</version>
+            </dependency>
+            
+            <!-- Apache POI -->
+            <dependency>
+                <groupId>org.apache.poi</groupId>
+                <artifactId>poi</artifactId>
+                <version>${poi.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.poi</groupId>
+                <artifactId>poi-ooxml</artifactId>
+                <version>${poi.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.poi</groupId>
+                <artifactId>poi-scratchpad</artifactId>
+                <version>${poi.version}</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>