Parcourir la source

feat(parse-service): add OCR pipeline and task center integration

何文松 il y a 1 mois
Parent
commit
a20493d24e

+ 28 - 0
backend/parse-service/src/main/java/com/lingyue/parse/config/PaddleOcrProperties.java

@@ -0,0 +1,28 @@
+package com.lingyue.parse.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * PaddleOCR 配置属性
+ *
+ * 本地 GPU 服务器上的 OCR 服务,通过 HTTP API 调用。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "paddleocr")
+public class PaddleOcrProperties {
+
+    /**
+     * OCR 服务地址,例如:http://localhost:8866/ocr
+     */
+    private String serverUrl = "http://localhost:8866/ocr";
+
+    /**
+     * 调用超时时间(毫秒)
+     */
+    private int timeout = 30_000;
+}
+
+

+ 40 - 14
backend/parse-service/src/main/java/com/lingyue/parse/controller/ParseController.java

@@ -1,35 +1,61 @@
 package com.lingyue.parse.controller;
 package com.lingyue.parse.controller;
 
 
-import com.lingyue.parse.service.ParseService;
 import com.lingyue.common.domain.AjaxResult;
 import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.parse.service.ParseTaskCenterService;
+import com.lingyue.parse.service.ParseTaskExecutor;
+import com.lingyue.parse.vo.ParseTaskCenterVO;
+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.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
 
 
 /**
 /**
- * 解析控制器(基础框架)
+ * 解析控制器
  */
  */
 @RestController
 @RestController
 @RequestMapping("/parse")
 @RequestMapping("/parse")
 @RequiredArgsConstructor
 @RequiredArgsConstructor
+@Tag(name = "解析任务", description = "解析任务启动与状态查询接口")
 public class ParseController {
 public class ParseController {
-    
-    private final ParseService parseService;
-    
+
+    private final ParseTaskExecutor parseTaskExecutor;
+    private final ParseTaskCenterService taskCenterService;
+
     /**
     /**
-     * 启动解析(待实现)
+     * 启动解析
+     *
+     * @param documentId 文档ID
+     * @param filePath 原始文件路径
      */
      */
     @PostMapping("/start")
     @PostMapping("/start")
-    public AjaxResult<?> startParse() {
-        // TODO: 实现解析启动逻辑
-        return AjaxResult.success("解析启动接口待实现");
+    @Operation(summary = "启动解析任务")
+    public AjaxResult<?> startParse(
+            @Parameter(description = "文档ID", required = true)
+            @RequestParam("documentId") String documentId,
+            @Parameter(description = "原始文件路径", required = true)
+            @RequestParam("filePath") String filePath) {
+
+        parseTaskExecutor.submitParseTask(documentId, filePath);
+        return AjaxResult.success("解析任务已提交");
     }
     }
-    
+
     /**
     /**
-     * 查询解析状态(待实现)
+     * 查询解析状态(按文档ID
      */
      */
     @GetMapping("/status/{documentId}")
     @GetMapping("/status/{documentId}")
+    @Operation(summary = "查询解析任务状态")
     public AjaxResult<?> getParseStatus(@PathVariable String documentId) {
     public AjaxResult<?> getParseStatus(@PathVariable String documentId) {
-        // TODO: 实现解析状态查询
-        return AjaxResult.success("解析状态查询接口待实现");
+        ParseTaskCenterVO detail = taskCenterService.getTaskDetailByDocumentId(documentId);
+        if (detail == null) {
+            return AjaxResult.error("解析任务不存在");
+        }
+        return AjaxResult.success(detail);
     }
     }
 }
 }
+

+ 90 - 0
backend/parse-service/src/main/java/com/lingyue/parse/controller/ParseTaskCenterController.java

@@ -0,0 +1,90 @@
+package com.lingyue.parse.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.parse.service.ParseTaskCenterService;
+import com.lingyue.parse.vo.ParseTaskCenterVO;
+import com.lingyue.parse.vo.ParseTaskStatisticsVO;
+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 org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 解析任务中心控制器
+ */
+@RestController
+@RequestMapping("/api/v1/tasks")
+@RequiredArgsConstructor
+@Tag(name = "解析任务中心", description = "解析任务列表、详情、统计管理接口")
+public class ParseTaskCenterController {
+
+    private final ParseTaskCenterService taskCenterService;
+
+    @GetMapping("/list")
+    @Operation(summary = "查询解析任务列表")
+    @Parameter(name = "status", description = "任务状态,可选: pending, processing, completed, failed")
+    @Parameter(name = "pageNum", description = "页码", example = "1")
+    @Parameter(name = "pageSize", description = "每页数量", example = "10")
+    public AjaxResult<?> getTaskList(
+            @RequestParam(value = "status", required = false) String status,
+            @RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
+
+        Page<ParseTaskCenterVO> page = taskCenterService.getTaskList(status, pageNum, pageSize);
+        return AjaxResult.success(page);
+    }
+
+    @GetMapping("/{taskId}/detail")
+    @Operation(summary = "查询解析任务详情")
+    public AjaxResult<?> getTaskDetail(@PathVariable("taskId") String taskId) {
+        ParseTaskCenterVO detail = taskCenterService.getTaskDetail(taskId);
+        if (detail == null) {
+            return AjaxResult.error("任务不存在");
+        }
+        return AjaxResult.success(detail);
+    }
+
+    @GetMapping("/by-document/{documentId}")
+    @Operation(summary = "根据文档ID查询解析任务详情")
+    public AjaxResult<?> getTaskDetailByDocumentId(
+            @PathVariable("documentId") String documentId) {
+
+        ParseTaskCenterVO detail = taskCenterService.getTaskDetailByDocumentId(documentId);
+        if (detail == null) {
+            return AjaxResult.error("任务不存在");
+        }
+        return AjaxResult.success(detail);
+    }
+
+    @GetMapping("/statistics")
+    @Operation(summary = "查询解析任务统计信息")
+    public AjaxResult<ParseTaskStatisticsVO> getTaskStatistics() {
+        ParseTaskStatisticsVO statistics = taskCenterService.getTaskStatistics();
+        return AjaxResult.success(statistics);
+    }
+
+    @DeleteMapping("/{taskId}")
+    @Operation(summary = "删除解析任务", description = "仅允许删除已完成或失败的任务")
+    public AjaxResult<?> deleteTask(@PathVariable("taskId") String taskId) {
+        try {
+            boolean deleted = taskCenterService.deleteTask(taskId);
+            if (!deleted) {
+                return AjaxResult.error("任务不存在");
+            }
+            return AjaxResult.success("删除成功");
+        } catch (IllegalStateException e) {
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            return AjaxResult.error("删除失败: " + e.getMessage());
+        }
+    }
+}
+
+

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

@@ -32,6 +32,7 @@ import java.util.UUID;
 public class FileUploadService {
 public class FileUploadService {
     
     
     private final FileStorageProperties fileStorageProperties;
     private final FileStorageProperties fileStorageProperties;
+    private final ParseTaskExecutor parseTaskExecutor;
     
     
     /**
     /**
      * 上传文件
      * 上传文件
@@ -62,8 +63,11 @@ public class FileUploadService {
             log.error("文件保存失败", e);
             log.error("文件保存失败", e);
             throw new ServiceException("文件保存失败: " + e.getMessage());
             throw new ServiceException("文件保存失败: " + e.getMessage());
         }
         }
+
+        // 6. 提交解析任务(异步执行)
+        parseTaskExecutor.submitParseTask(documentId, filePath);
         
         
-        // 6. 构建响应
+        // 7. 构建响应
         return FileUploadResponse.builder()
         return FileUploadResponse.builder()
                 .documentId(documentId)
                 .documentId(documentId)
                 .fileName(file.getOriginalFilename())
                 .fileName(file.getOriginalFilename())

+ 93 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/PaddleOcrClient.java

@@ -0,0 +1,93 @@
+package com.lingyue.parse.service;
+
+import com.lingyue.common.exception.ServiceException;
+import com.lingyue.parse.config.PaddleOcrProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * PaddleOCR 客户端,用于调用本地 GPU 服务器上的 OCR 接口。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PaddleOcrClient {
+
+    private final PaddleOcrProperties ocrProperties;
+
+    /**
+     * 对本地文件执行 OCR 识别,返回原始 JSON 字符串
+     *
+     * @param filePath 待识别的文件路径
+     * @return OCR 结果 JSON 字符串
+     */
+    public String ocrFile(String filePath) {
+        Path path = Path.of(filePath);
+        if (!Files.exists(path)) {
+            throw new ServiceException("待识别文件不存在: " + filePath);
+        }
+
+        try {
+            byte[] bytes = Files.readAllBytes(path);
+
+            // 构造 multipart/form-data 请求体,字段名为 file(可根据实际服务调整)
+            ByteArrayResource fileResource = new ByteArrayResource(bytes) {
+                @Override
+                public String getFilename() {
+                    return path.getFileName().toString();
+                }
+            };
+
+            MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+            body.add("file", fileResource);
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+
+            HttpEntity<MultiValueMap<String, Object>> requestEntity =
+                    new HttpEntity<>(body, headers);
+
+            RestTemplate restTemplate = new RestTemplate();
+            String url = ocrProperties.getServerUrl();
+
+            log.info("调用 PaddleOCR 服务, url={}, file={}", url, filePath);
+
+            ResponseEntity<String> response = restTemplate.exchange(
+                    url,
+                    HttpMethod.POST,
+                    requestEntity,
+                    String.class
+            );
+
+            if (!response.getStatusCode().is2xxSuccessful()) {
+                throw new ServiceException("调用 OCR 服务失败, HTTP 状态码: " + response.getStatusCodeValue());
+            }
+
+            String bodyStr = response.getBody();
+            log.debug("PaddleOCR 响应: {}", bodyStr);
+            return bodyStr;
+        } catch (IOException e) {
+            log.error("读取待 OCR 文件失败: {}", filePath, e);
+            throw new ServiceException("读取待 OCR 文件失败: " + e.getMessage());
+        } catch (Exception e) {
+            log.error("调用 PaddleOCR 服务异常", e);
+            throw new ServiceException("调用 PaddleOCR 服务异常: " + e.getMessage());
+        }
+    }
+}
+
+

+ 123 - 9
backend/parse-service/src/main/java/com/lingyue/parse/service/ParseService.java

@@ -1,33 +1,45 @@
 package com.lingyue.parse.service;
 package com.lingyue.parse.service;
 
 
+import com.lingyue.parse.config.FileStorageProperties;
 import com.lingyue.parse.entity.ParseTask;
 import com.lingyue.parse.entity.ParseTask;
 import com.lingyue.parse.repository.ParseTaskRepository;
 import com.lingyue.parse.repository.ParseTaskRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
 /**
 /**
- * 解析服务(基础框架)
+ * 解析服务
+ *
+ * 负责管理解析任务、调用 OCR 服务以及将解析后的文本写入 TXT 文件。
  */
  */
+@Slf4j
 @Service
 @Service
 @RequiredArgsConstructor
 @RequiredArgsConstructor
 public class ParseService {
 public class ParseService {
-    
+
     private final ParseTaskRepository parseTaskRepository;
     private final ParseTaskRepository parseTaskRepository;
-    
+    private final PaddleOcrClient paddleOcrClient;
+    private final FileStorageProperties fileStorageProperties;
+
     /**
     /**
      * 根据ID获取解析任务
      * 根据ID获取解析任务
      */
      */
     public ParseTask getParseTaskById(String taskId) {
     public ParseTask getParseTaskById(String taskId) {
         return parseTaskRepository.selectById(taskId);
         return parseTaskRepository.selectById(taskId);
     }
     }
-    
+
     /**
     /**
      * 根据文档ID获取解析任务
      * 根据文档ID获取解析任务
      */
      */
     public ParseTask getParseTaskByDocumentId(String documentId) {
     public ParseTask getParseTaskByDocumentId(String documentId) {
         return parseTaskRepository.findByDocumentId(documentId);
         return parseTaskRepository.findByDocumentId(documentId);
     }
     }
-    
+
     /**
     /**
      * 保存解析任务
      * 保存解析任务
      */
      */
@@ -35,6 +47,7 @@ public class ParseService {
         if (parseTask.getId() == null) {
         if (parseTask.getId() == null) {
             parseTask.setId(java.util.UUID.randomUUID().toString().replace("-", ""));
             parseTask.setId(java.util.UUID.randomUUID().toString().replace("-", ""));
             parseTask.setCreateTime(new java.util.Date());
             parseTask.setCreateTime(new java.util.Date());
+            parseTask.setStartedAt(new java.util.Date());
             parseTaskRepository.insert(parseTask);
             parseTaskRepository.insert(parseTask);
         } else {
         } else {
             parseTask.setUpdateTime(new java.util.Date());
             parseTask.setUpdateTime(new java.util.Date());
@@ -42,8 +55,109 @@ public class ParseService {
         }
         }
         return parseTask;
         return parseTask;
     }
     }
-    
-    // TODO: 实现PaddleOCR调用逻辑
-    // TODO: 实现文本提取和版面分析
-    // TODO: 实现异步任务处理
+
+    /**
+     * 对指定文档执行 OCR 并将结果写入 TXT 文件
+     *
+     * 当前实现为同步调用,后续可以改为异步任务。
+     *
+     * @param documentId 文档ID
+     * @param sourceFilePath 原始文件路径
+     * @return 更新后的解析任务
+     */
+    public ParseTask runOcrAndSaveText(String documentId, String sourceFilePath) {
+        // 1. 初始化或更新解析任务
+        ParseTask task = getOrCreateTask(documentId);
+        task.setStatus("processing");
+        task.setCurrentStep("ocr");
+        task.setProgress(10);
+        saveParseTask(task);
+
+        try {
+            // 2. 调用 OCR 服务
+            String ocrResult = paddleOcrClient.ocrFile(sourceFilePath);
+
+            // TODO: 根据实际返回结构,将 ocrResult 解析为纯文本
+            String plainText = extractPlainTextFromOcrResult(ocrResult);
+
+            // 3. 将纯文本写入 TXT 文件
+            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.info("OCR 文本已写入: {}", textFilePath);
+
+            // TODO: 在此处调用 graph-service 或 document-service 记录 text_storage 信息
+
+            // 4. 更新任务状态为完成
+            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);
+            task.setStatus("failed");
+            task.setCurrentStep("failed");
+            task.setErrorMessage(e.getMessage());
+            saveParseTask(task);
+            throw e;
+        }
+
+        return task;
+    }
+
+    /**
+     * 获取或创建解析任务
+     */
+    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());
+        Files.writeString(path, content, StandardCharsets.UTF_8);
+    }
+
+    /**
+     * 从 OCR 返回结果中提取纯文本
+     *
+     * 这里先做一个简单占位实现:直接返回原始 JSON,
+     * 后续可以根据你本地 GPU 服务的返回结构进行 JSON 解析与拼接。
+     */
+    private String extractPlainTextFromOcrResult(String ocrResult) {
+        // TODO: 根据实际返回结构提取文字内容
+        return ocrResult == null ? "" : ocrResult;
+    }
+
+    // TODO: 实现版面分析
+    // TODO: 实现异步任务处理(MQ / 线程池等)
 }
 }
+

+ 198 - 0
backend/parse-service/src/main/java/com/lingyue/parse/service/ParseTaskCenterService.java

@@ -0,0 +1,198 @@
+package com.lingyue.parse.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.lingyue.parse.entity.ParseTask;
+import com.lingyue.parse.repository.ParseTaskRepository;
+import com.lingyue.parse.vo.ParseTaskCenterVO;
+import com.lingyue.parse.vo.ParseTaskStageVO;
+import com.lingyue.parse.vo.ParseTaskStatisticsVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 解析任务中心 Service
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ParseTaskCenterService {
+
+    private final ParseTaskRepository parseTaskRepository;
+
+    /**
+     * 分页查询任务列表
+     */
+    public Page<ParseTaskCenterVO> getTaskList(String status, Integer pageNum, Integer pageSize) {
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+
+        LambdaQueryWrapper<ParseTask> wrapper = new LambdaQueryWrapper<>();
+        if (status != null && !status.isBlank()) {
+            wrapper.eq(ParseTask::getStatus, status);
+        }
+        wrapper.orderByDesc(ParseTask::getCreateTime);
+
+        Page<ParseTask> page = new Page<>(pageNum, pageSize);
+        Page<ParseTask> taskPage = parseTaskRepository.selectPage(page, wrapper);
+
+        Page<ParseTaskCenterVO> result = new Page<>();
+        result.setCurrent(taskPage.getCurrent());
+        result.setSize(taskPage.getSize());
+        result.setTotal(taskPage.getTotal());
+
+        List<ParseTaskCenterVO> voList = new ArrayList<>();
+        for (ParseTask task : taskPage.getRecords()) {
+            voList.add(toCenterVO(task));
+        }
+        result.setRecords(voList);
+        return result;
+    }
+
+    /**
+     * 查询任务详情
+     */
+    public ParseTaskCenterVO getTaskDetail(String taskId) {
+        ParseTask task = parseTaskRepository.selectById(taskId);
+        if (task == null) {
+            return null;
+        }
+        return toCenterVO(task);
+    }
+
+    /**
+     * 根据文档ID查询任务详情
+     */
+    public ParseTaskCenterVO getTaskDetailByDocumentId(String documentId) {
+        ParseTask task = parseTaskRepository.findByDocumentId(documentId);
+        if (task == null) {
+            return null;
+        }
+        return toCenterVO(task);
+    }
+
+    /**
+     * 统计信息
+     */
+    public ParseTaskStatisticsVO getTaskStatistics() {
+        ParseTaskStatisticsVO vo = new ParseTaskStatisticsVO();
+        vo.setTotal(countByStatus(null));
+        vo.setPending(countByStatus("pending"));
+        vo.setProcessing(countByStatus("processing"));
+        vo.setCompleted(countByStatus("completed"));
+        vo.setFailed(countByStatus("failed"));
+        return vo;
+    }
+
+    private long countByStatus(String status) {
+        LambdaQueryWrapper<ParseTask> wrapper = new LambdaQueryWrapper<>();
+        if (status != null) {
+            wrapper.eq(ParseTask::getStatus, status);
+        }
+        return parseTaskRepository.selectCount(wrapper);
+    }
+
+    /**
+     * 删除任务(进行中的任务不允许删除)
+     */
+    public boolean deleteTask(String taskId) {
+        ParseTask task = parseTaskRepository.selectById(taskId);
+        if (task == null) {
+            return false;
+        }
+        if ("processing".equals(task.getStatus())) {
+            throw new IllegalStateException("运行中的任务不允许删除");
+        }
+        return parseTaskRepository.deleteById(taskId) > 0;
+    }
+
+    /**
+     * Entity -> VO 映射 + 阶段列表组装
+     */
+    private ParseTaskCenterVO toCenterVO(ParseTask task) {
+        ParseTaskCenterVO vo = new ParseTaskCenterVO();
+        vo.setId(task.getId());
+        vo.setDocumentId(task.getDocumentId());
+        vo.setStatus(task.getStatus());
+        vo.setProgress(task.getProgress());
+        vo.setCurrentStep(task.getCurrentStep());
+        vo.setErrorMessage(task.getErrorMessage());
+        vo.setStartedAt(task.getStartedAt());
+        vo.setCompletedAt(task.getCompletedAt());
+        vo.setCreatedAt(task.getCreateTime());
+        vo.setUpdatedAt(task.getUpdateTime());
+        vo.setName("ParseTask-" + task.getDocumentId());
+        vo.setStages(buildStages(task));
+        return vo;
+    }
+
+    /**
+     * 简单阶段构建:上传、OCR+TXT、NER、图构建
+     */
+    private List<ParseTaskStageVO> buildStages(ParseTask task) {
+        List<ParseTaskStageVO> stages = new ArrayList<>();
+
+        ParseTaskStageVO upload = new ParseTaskStageVO();
+        upload.setStageName("upload");
+        upload.setDisplayName("文件上传");
+        upload.setStatus("completed");
+        upload.setProgress(100);
+        upload.setStartedAt(task.getCreateTime());
+        upload.setEndedAt(task.getCreateTime());
+        stages.add(upload);
+
+        ParseTaskStageVO ocr = new ParseTaskStageVO();
+        ocr.setStageName("ocr");
+        ocr.setDisplayName("OCR解析 & 文本存储");
+        ocr.setStartedAt(task.getStartedAt());
+
+        String status = task.getStatus();
+        if ("pending".equals(status)) {
+            ocr.setStatus("pending");
+            ocr.setProgress(0);
+        } else if ("processing".equals(status)) {
+            ocr.setStatus("in_progress");
+            ocr.setProgress(task.getProgress() != null ? task.getProgress() : 0);
+        } else if ("completed".equals(status)) {
+            ocr.setStatus("completed");
+            ocr.setProgress(100);
+            ocr.setEndedAt(task.getCompletedAt());
+        } else if ("failed".equals(status)) {
+            ocr.setStatus("failed");
+            ocr.setProgress(task.getProgress() != null ? task.getProgress() : 0);
+            ocr.setEndedAt(task.getCompletedAt());
+            ocr.setErrorMessage(task.getErrorMessage());
+        } else {
+            ocr.setStatus("pending");
+            ocr.setProgress(0);
+        }
+
+        stages.add(ocr);
+
+        ParseTaskStageVO ner = new ParseTaskStageVO();
+        ner.setStageName("ner");
+        ner.setDisplayName("实体提取(NER)");
+        ner.setStatus("pending");
+        ner.setProgress(0);
+        stages.add(ner);
+
+        ParseTaskStageVO graph = new ParseTaskStageVO();
+        graph.setStageName("graph");
+        graph.setDisplayName("图构建");
+        graph.setStatus("pending");
+        graph.setProgress(0);
+        stages.add(graph);
+
+        return stages;
+    }
+}
+
+

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

@@ -0,0 +1,48 @@
+package com.lingyue.parse.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 解析任务执行器(当前使用单线程队列,符合GPU线性处理约束)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ParseTaskExecutor {
+
+    private final ParseService parseService;
+
+    private final ThreadPoolTaskExecutor executor;
+
+    public ParseTaskExecutor(ParseService parseService) {
+        this.parseService = parseService;
+        ThreadPoolTaskExecutor tp = new ThreadPoolTaskExecutor();
+        tp.setCorePoolSize(1);
+        tp.setMaxPoolSize(1);
+        tp.setQueueCapacity(1000);
+        tp.setThreadNamePrefix("parse-task-");
+        tp.initialize();
+        this.executor = tp;
+    }
+
+    /**
+     * 提交解析任务(异步执行)
+     *
+     * @param documentId 文档ID
+     * @param sourceFilePath 原始文件路径
+     */
+    public void submitParseTask(String documentId, String sourceFilePath) {
+        executor.submit(() -> {
+            try {
+                parseService.runOcrAndSaveText(documentId, sourceFilePath);
+            } catch (Exception e) {
+                log.error("解析任务执行失败, documentId={}", documentId, e);
+            }
+        });
+    }
+}
+
+

+ 56 - 0
backend/parse-service/src/main/java/com/lingyue/parse/vo/ParseTaskCenterVO.java

@@ -0,0 +1,56 @@
+package com.lingyue.parse.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 解析任务中心视图对象
+ */
+@Data
+@Schema(description = "解析任务中心视图对象")
+public class ParseTaskCenterVO {
+
+    @Schema(description = "任务ID")
+    private String id;
+
+    @Schema(description = "文档ID")
+    private String documentId;
+
+    @Schema(description = "任务类型,当前固定为 parse")
+    private String taskType = "parse";
+
+    @Schema(description = "任务名称")
+    private String name;
+
+    @Schema(description = "状态: pending/processing/completed/failed")
+    private String status;
+
+    @Schema(description = "整体进度(0-100)")
+    private Integer progress;
+
+    @Schema(description = "当前步骤")
+    private String currentStep;
+
+    @Schema(description = "错误消息")
+    private String errorMessage;
+
+    @Schema(description = "开始时间")
+    private Date startedAt;
+
+    @Schema(description = "完成时间")
+    private Date completedAt;
+
+    @Schema(description = "创建时间")
+    private Date createdAt;
+
+    @Schema(description = "更新时间")
+    private Date updatedAt;
+
+    @Schema(description = "阶段列表")
+    private List<ParseTaskStageVO> stages;
+}
+
+

+ 37 - 0
backend/parse-service/src/main/java/com/lingyue/parse/vo/ParseTaskStageVO.java

@@ -0,0 +1,37 @@
+package com.lingyue.parse.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 解析任务阶段信息
+ */
+@Data
+@Schema(description = "解析任务阶段信息")
+public class ParseTaskStageVO {
+
+    @Schema(description = "阶段名称,例如: upload, ocr, text_save, ner, graph")
+    private String stageName;
+
+    @Schema(description = "阶段显示名称")
+    private String displayName;
+
+    @Schema(description = "阶段状态: pending/in_progress/completed/failed")
+    private String status;
+
+    @Schema(description = "阶段进度(0-100)")
+    private Integer progress;
+
+    @Schema(description = "开始时间")
+    private Date startedAt;
+
+    @Schema(description = "结束时间")
+    private Date endedAt;
+
+    @Schema(description = "阶段错误信息")
+    private String errorMessage;
+}
+
+

+ 29 - 0
backend/parse-service/src/main/java/com/lingyue/parse/vo/ParseTaskStatisticsVO.java

@@ -0,0 +1,29 @@
+package com.lingyue.parse.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 解析任务统计信息
+ */
+@Data
+@Schema(description = "解析任务统计信息")
+public class ParseTaskStatisticsVO {
+
+    @Schema(description = "总任务数")
+    private Long total;
+
+    @Schema(description = "进行中任务数")
+    private Long processing;
+
+    @Schema(description = "已完成任务数")
+    private Long completed;
+
+    @Schema(description = "失败任务数")
+    private Long failed;
+
+    @Schema(description = "等待中任务数")
+    private Long pending;
+}
+
+

+ 66 - 0
frontend_flutter/lib/models/task.dart

@@ -0,0 +1,66 @@
+import 'task_stage.dart';
+
+class Task {
+  final String id;
+  final String documentId;
+  final String taskType;
+  final String name;
+  final String status;
+  final int progress;
+  final String? currentStep;
+  final String? errorMessage;
+  final DateTime? startedAt;
+  final DateTime? completedAt;
+  final DateTime? createdAt;
+  final DateTime? updatedAt;
+  final List<TaskStage> stages;
+
+  Task({
+    required this.id,
+    required this.documentId,
+    required this.taskType,
+    required this.name,
+    required this.status,
+    required this.progress,
+    this.currentStep,
+    this.errorMessage,
+    this.startedAt,
+    this.completedAt,
+    this.createdAt,
+    this.updatedAt,
+    required this.stages,
+  });
+
+  factory Task.fromJson(Map<String, dynamic> data) {
+    return Task(
+      id: data['id'] != null ? data['id'].toString() : '',
+      documentId:
+          data['documentId'] != null ? data['documentId'].toString() : '',
+      taskType: data['taskType'] != null ? data['taskType'].toString() : 'parse',
+      name: data['name'] != null ? data['name'].toString() : '',
+      status: data['status'] != null ? data['status'].toString() : 'pending',
+      progress: (data['progress'] ?? 0) as int,
+      currentStep:
+          data['currentStep'] != null ? data['currentStep'].toString() : null,
+      errorMessage:
+          data['errorMessage'] != null ? data['errorMessage'].toString() : null,
+      startedAt: data['startedAt'] != null
+          ? DateTime.parse(data['startedAt'].toString())
+          : null,
+      completedAt: data['completedAt'] != null
+          ? DateTime.parse(data['completedAt'].toString())
+          : null,
+      createdAt: data['createdAt'] != null
+          ? DateTime.parse(data['createdAt'].toString())
+          : null,
+      updatedAt: data['updatedAt'] != null
+          ? DateTime.parse(data['updatedAt'].toString())
+          : null,
+      stages: (data['stages'] as List<dynamic>? ?? [])
+          .map((e) => TaskStage.fromJson(e as Map<String, dynamic>))
+          .toList(),
+    );
+  }
+}
+
+

+ 37 - 0
frontend_flutter/lib/models/task_stage.dart

@@ -0,0 +1,37 @@
+class TaskStage {
+  final String stageName;
+  final String displayName;
+  final String status; // pending / in_progress / completed / failed
+  final int progress;
+  final DateTime? startedAt;
+  final DateTime? endedAt;
+  final String? errorMessage;
+
+  TaskStage({
+    required this.stageName,
+    required this.displayName,
+    required this.status,
+    required this.progress,
+    this.startedAt,
+    this.endedAt,
+    this.errorMessage,
+  });
+
+  factory TaskStage.fromJson(Map<String, dynamic> json) {
+    return TaskStage(
+      stageName: json['stageName'] ?? '',
+      displayName: json['displayName'] ?? '',
+      status: json['status'] ?? 'pending',
+      progress: (json['progress'] ?? 0) as int,
+      startedAt: json['startedAt'] != null
+          ? DateTime.parse(json['startedAt'] as String)
+          : null,
+      endedAt: json['endedAt'] != null
+          ? DateTime.parse(json['endedAt'] as String)
+          : null,
+      errorMessage: json['errorMessage'] as String?,
+    );
+  }
+}
+
+

+ 151 - 0
frontend_flutter/lib/pages/task_center_page.dart

@@ -0,0 +1,151 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../models/task.dart';
+import '../providers/task_provider.dart';
+import '../widgets/common/app_card.dart';
+import '../widgets/common/app_progress.dart';
+import '../widgets/common/app_tag.dart';
+import '../theme/app_colors.dart';
+
+class TaskCenterPage extends StatefulWidget {
+  const TaskCenterPage({super.key});
+
+  @override
+  State<TaskCenterPage> createState() => _TaskCenterPageState();
+}
+
+class _TaskCenterPageState extends State<TaskCenterPage> {
+  @override
+  void initState() {
+    super.initState();
+    Future.microtask(() {
+      context.read<TaskProvider>().fetchTasks();
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final provider = context.watch<TaskProvider>();
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('任务中心'),
+      ),
+      body: Padding(
+        padding: const EdgeInsets.all(16),
+        child: provider.loading
+            ? const Center(child: CircularProgressIndicator())
+            : Column(
+                children: [
+                  _buildFilterBar(provider),
+                  const SizedBox(height: 12),
+                  Expanded(
+                    child: ListView.builder(
+                      itemCount: provider.tasks.length,
+                      itemBuilder: (context, index) {
+                        final task = provider.tasks[index];
+                        return _buildTaskCard(task);
+                      },
+                    ),
+                  ),
+                ],
+              ),
+      ),
+    );
+  }
+
+  Widget _buildFilterBar(TaskProvider provider) {
+    return Row(
+      children: [
+        DropdownButton<String>(
+          value: provider.statusFilter ?? '',
+          items: const [
+            DropdownMenuItem(value: '', child: Text('全部')),
+            DropdownMenuItem(value: 'pending', child: Text('等待中')),
+            DropdownMenuItem(value: 'processing', child: Text('进行中')),
+            DropdownMenuItem(value: 'completed', child: Text('已完成')),
+            DropdownMenuItem(value: 'failed', child: Text('失败')),
+          ],
+          onChanged: (value) {
+            provider.fetchTasks(status: value?.isEmpty == true ? null : value);
+          },
+        ),
+        const Spacer(),
+        Text('共 ${provider.total} 个任务'),
+      ],
+    );
+  }
+
+  Widget _buildTaskCard(Task task) {
+    Color statusColor;
+    String statusLabel;
+    switch (task.status) {
+      case 'processing':
+        statusColor = AppColors.primary;
+        statusLabel = '进行中';
+        break;
+      case 'completed':
+        statusColor = Colors.green;
+        statusLabel = '已完成';
+        break;
+      case 'failed':
+        statusColor = Colors.red;
+        statusLabel = '失败';
+        break;
+      default:
+        statusColor = Colors.grey;
+        statusLabel = '等待中';
+    }
+
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 12),
+      child: AppCard(
+        child: Padding(
+          padding: const EdgeInsets.all(12),
+          child: Row(
+            children: [
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      task.name.isNotEmpty ? task.name : task.id,
+                      style: const TextStyle(
+                        fontSize: 16,
+                        fontWeight: FontWeight.bold,
+                      ),
+                    ),
+                    const SizedBox(height: 4),
+                    Text('文档: ${task.documentId}'),
+                    const SizedBox(height: 4),
+                    if (task.errorMessage != null &&
+                        task.errorMessage!.isNotEmpty)
+                      Text(
+                        task.errorMessage!,
+                        style: const TextStyle(
+                          fontSize: 12,
+                          color: Colors.redAccent,
+                        ),
+                        maxLines: 1,
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                    const SizedBox(height: 8),
+                    AppProgress(
+                      value: task.progress / 100.0,
+                      height: 6,
+                    ),
+                  ],
+                ),
+              ),
+              const SizedBox(width: 12),
+              AppTag(label: statusLabel, color: statusColor),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+

+ 105 - 0
frontend_flutter/lib/providers/task_provider.dart

@@ -0,0 +1,105 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+
+import '../models/task.dart';
+
+class TaskProvider extends ChangeNotifier {
+  List<Task> _tasks = [];
+  int _pageNum = 1;
+  int _pageSize = 10;
+  int _total = 0;
+  String? _statusFilter;
+  bool _loading = false;
+
+  List<Task> get tasks => _tasks;
+  int get pageNum => _pageNum;
+  int get pageSize => _pageSize;
+  int get total => _total;
+  String? get statusFilter => _statusFilter;
+  bool get loading => _loading;
+
+  // TODO: 抽到配置
+  final String baseUrl = 'http://localhost:8003';
+
+  Future<void> fetchTasks({String? status, int? pageNum, int? pageSize}) async {
+    _loading = true;
+    notifyListeners();
+
+    _statusFilter = status ?? _statusFilter;
+    _pageNum = pageNum ?? _pageNum;
+    _pageSize = pageSize ?? _pageSize;
+
+    final query = <String, String>{
+      'pageNum': _pageNum.toString(),
+      'pageSize': _pageSize.toString(),
+    };
+    if (_statusFilter != null && _statusFilter!.isNotEmpty) {
+      query['status'] = _statusFilter!;
+    }
+
+    final uri = Uri.parse('$baseUrl/api/v1/tasks/list').replace(
+      queryParameters: query,
+    );
+
+    try {
+      final resp = await http.get(uri);
+      if (resp.statusCode == 200) {
+        final body = json.decode(resp.body) as Map<String, dynamic>;
+        final data = body['data'] as Map<String, dynamic>;
+        final records = data['records'] as List<dynamic>? ?? [];
+        _total = (data['total'] ?? 0) as int;
+        _tasks = records
+            .map((e) => Task.fromJson(e as Map<String, dynamic>))
+            .toList();
+      } else {
+        if (kDebugMode) {
+          print('fetchTasks failed: ${resp.statusCode} ${resp.body}');
+        }
+      }
+    } catch (e) {
+      if (kDebugMode) {
+        print('fetchTasks error: $e');
+      }
+    } finally {
+      _loading = false;
+      notifyListeners();
+    }
+  }
+
+  Future<Task?> fetchTaskDetail(String taskId) async {
+    final uri = Uri.parse('$baseUrl/api/v1/tasks/$taskId/detail');
+    try {
+      final resp = await http.get(uri);
+      if (resp.statusCode == 200) {
+        final body = json.decode(resp.body) as Map<String, dynamic>;
+        final data = body['data'] as Map<String, dynamic>;
+        return Task.fromJson(data);
+      }
+    } catch (e) {
+      if (kDebugMode) {
+        print('fetchTaskDetail error: $e');
+      }
+    }
+    return null;
+  }
+
+  Future<void> deleteTask(String taskId) async {
+    final uri = Uri.parse('$baseUrl/api/v1/tasks/$taskId');
+    try {
+      final resp = await http.delete(uri);
+      if (resp.statusCode == 200) {
+        _tasks.removeWhere((t) => t.id == taskId);
+        _total = _total > 0 ? _total - 1 : 0;
+        notifyListeners();
+      }
+    } catch (e) {
+      if (kDebugMode) {
+        print('deleteTask error: $e');
+      }
+    }
+  }
+}
+
+

+ 6 - 0
frontend_flutter/lib/router/app_router.dart

@@ -6,6 +6,7 @@ import '../pages/parse_compare_page.dart';
 import '../pages/ai_process_page.dart';
 import '../pages/ai_process_page.dart';
 import '../pages/traction_page.dart';
 import '../pages/traction_page.dart';
 import '../pages/result_page.dart';
 import '../pages/result_page.dart';
+import '../pages/task_center_page.dart';
 import '../utils/constants.dart';
 import '../utils/constants.dart';
 
 
 /// 应用路由配置
 /// 应用路由配置
@@ -24,6 +25,11 @@ class AppRouter {
         name: 'home',
         name: 'home',
         builder: (context, state) => const DashboardPage(),
         builder: (context, state) => const DashboardPage(),
       ),
       ),
+      GoRoute(
+        path: AppRoutes.tasks,
+        name: 'tasks',
+        builder: (context, state) => const TaskCenterPage(),
+      ),
       GoRoute(
       GoRoute(
         path: AppRoutes.upload,
         path: AppRoutes.upload,
         name: 'upload',
         name: 'upload',

+ 1 - 0
frontend_flutter/lib/utils/constants.dart

@@ -48,4 +48,5 @@ class AppRoutes {
   static const String result = '/result';
   static const String result = '/result';
   static const String documents = '/documents';
   static const String documents = '/documents';
   static const String settings = '/settings';
   static const String settings = '/settings';
+  static const String tasks = '/tasks';
 }
 }