Skip to content

多文档解析 ​

当前实现与接口说明 ​

Java 后端先检测文件实际内容,再选择解析策略。当前处理 PDF、DOCX、DOC、XLS/XLSX、常用文本和 HTML、可识别图片及受限 ZIP 文档集合。不要仅凭文件扩展名决定解析方式,也不要将尚未实现的格式列为已支持。

文字 PDF 使用文本提取,扫描页使用 Gitee PaddleOCR-VL-1.5,混合 PDF 逐页选择;DOCX 保留正文和表格,正文极少且含图片时对内嵌图片 OCR;表格按工作表提取。完整格式、容量限制和策略标识

文档检测与解析策略 ​

解析服务先检查内容,再选择路径。PDF 通过文件头识别,即使扩展名不正确也能识别。

文档特征策略结果标识
可提取有效文字的 PDF 页面按阅读顺序提取文字,保留页码pdf-text
扫描 PDF 页面或无法可靠提取文字的页面调用 PaddleOCR,按原页序合并pdf-ocr
同时含文字页和扫描页的 PDF逐页选择提取或 OCRpdf-mixed
有页面重试后仍然 OCR 失败同上,标识加后缀,失败页内容为空pdf-ocr-partial、pdf-mixed-partial
DOCX 正文与表格提取段落、表格结构docx-structure
DOCX 正文极少但含图片提取内嵌图片并 OCRdocx-ocr
DOC提取正文doc-text
XLS、XLSX按工作表和行提取显示值spreadsheet-structure
TXT、Markdown、日志、CSV、JSON、HTML文本解码,HTML 提取可见正文text
PNG、JPEG、TIFF 等可识别图片OCRimage-ocr
ZIP在内存中读取文档并合并,不解压到磁盘zip-documents

空文件会被拒绝。单个上传文件最多 100 MB;ZIP 最多 50 个成员文件,解压后总量最多 100 MB,不支持嵌套 ZIP。DOCX 正文和图片另有解析容量限制。PDF 文字页检测采用启发式规则,复杂排版、文字层与图片混排的文档仍应检查分段预览。

OCR 结果按内容摘要缓存,空白结果不写缓存。正文用于分段和检索,不把 OCR 服务临时图片地址作为知识片段的必要内容。解析预览返回 parse_strategy、page_count、ocr_page_count、failed_page_count 和文件 id。

解析预览返回文件 id、策略与页数。前端提交确认后的分段时必须回传该文件 ID,后端据此建立文档关联。解析结果应由用户检查后再入库。上传接口只建任务并立即返回任务 ID,分段结果通过状态接口轮询取得,见文档解析异步化。

已实现能力一览 ​

输入判定依据解析方式策略标识
文字型 PDF头部 %PDF-PDFBox 逐页文本提取pdf-text
扫描型 PDF头部 %PDF-,各页可读字符不足逐页渲染后远程 OCRpdf-ocr
混合型 PDF头部 %PDF-,部分页有文本逐页在文本提取与 OCR 之间选择pdf-mixed
DOCXZIP 头 PK 且文件名以 .docx 结尾解析 word/document.xml 的段落与表格docx-structure
图片型 DOCX同上,但正文可读字符不足 40 且含 word/media/ 图片结构文本 + 内嵌图片 OCRdocx-ocr
XLS / XLSX文件名后缀按工作表逐行导出为 Markdown 表格spreadsheet-structure
DOC文件名后缀WordExtractor 提取纯文本doc-text
ZIP文件名后缀逐条目递归调用同一解析器zip-documents
TXT / MD / LOG / CSV / JSON文件名后缀UTF-8 解码,失败回退 GB18030text
HTML / HTM文件名后缀jsoup 去标签取正文text
PNG / JPG / JPEG / TIFF 等ImageIO.read 可解码直接送入远程 OCRimage-ocr

容量与安全边界 ​

限制项阈值触发结果
HTTP 请求体http.multipart.max-request-size,默认部署写 105 MB连接在解析请求头阶段被断开,日志为 Request body exceeds the configured limit
单个上传文件100 MB(前后端各校验一次)文件不能超过100MB
ZIP 内文件个数50 个,且不允许嵌套 ZIPZIP最多包含50个文件,不支持嵌套ZIP
ZIP 解压后总大小100 MBZIP解压后的内容不能超过100MB
DOCX 内嵌图片50 张,单张 20 MBDOCX内嵌图片过多或过大
表格10 万行、500 列、输出 20 MB表格内容过大,请拆分后上传
PDF 加密PDFBox isEncrypted()请先解密PDF文件
解析结果为空result.text().isBlank()未提取到内容,请检查文档或使用扫描PDF
每一页 OCR 都失败ocrPages == failedPages扫描页OCR识别失败,请稍后重试

DOCX 的 XML 解析关闭了 DTD 与外部实体,ZIP 采用流式读取且从不落地解压路径,因此压缩包里的相对路径不会被写入磁盘。

解析器:DocumentParsingService ​

解析入口是 parse(byte[] data, String filename),返回一个包含策略、正文、页数和 OCR 统计的记录对象;需要观察进度时用带回调的重载:

java
package nexus.io.mosskb.service.kb;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import nexus.io.gitee.*;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.crypto.Md5Utils;

/** Detect the actual document structure before selecting text extraction or OCR. */
public class DocumentParsingService {

  /** 一个页面提取到的内容。 */
  private record PageResult(int page, String text, boolean viaOcr, boolean failed) {}

  /**
   * 解析结果。
   *
   * @param strategy    解析策略
   * @param text        正文
   * @param pages       物理页数,非分页格式为 0
   * @param ocrPages    走 OCR 的页数
   * @param failedPages 尝试多次后仍然失败的页数
   */
  public record Parsed(String strategy, String text, int pages, int ocrPages, int failedPages) {
    /** 兼容只关心策略、正文和页数的调用方。 */
    public Parsed(String strategy, String text, int pages) {
      this(strategy, text, pages, 0, 0);
    }
  }

  /** 解析进度:已完成页数与总页数。 */
  public record Progress(int completed, int total) {
  }

  public Parsed parse(byte[] data, String filename) throws Exception {
    return parse(data, filename, null);
  }

  public Parsed parse(byte[] data, String filename, java.util.function.Consumer<Progress> progress) throws Exception {
    if (data == null || data.length == 0) {
      throw new IllegalArgumentException("文件为空");
    }
    if (data.length > 100 * 1024 * 1024) {
      throw new IllegalArgumentException("文件不能超过100MB");
    }
    String name = filename.toLowerCase(Locale.ROOT);
    Parsed result;
    if (new String(data, 0, Math.min(5, data.length), StandardCharsets.US_ASCII).equals("%PDF-")) {
      // PDF:逐页判断
    } else if (data.length > 4 && data[0] == 'P' && data[1] == 'K' && name.endsWith(".docx")) {
      // DOCX:结构提取,必要时补图片 OCR
    } else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
      // 表格:按工作表提取
    } else if (name.endsWith(".doc")) {
      // 旧版 Word
    } else if (name.endsWith(".zip")) {
      // 受限 ZIP 文档集合
    } else if (name.matches(".*\\.(txt|md|log|csv|json|html|htm)$")) {
      // 文本与 HTML
    } else if (javax.imageio.ImageIO.read(new ByteArrayInputStream(data)) != null) {
      // 图片:直接 OCR
      result = new Parsed("image-ocr", ocr(data, filename), 1, 1, 0);
    } else {
      throw new IllegalArgumentException("不支持的文件格式,请转换为PDF、DOCX、TXT或图片后上传");
    }
    if (result.text().isBlank()) {
      // 全部走 OCR 的文档如果每一页都失败,原因是远程服务不可用,不是文档本身没有内容。
      if (result.ocrPages() > 0 && result.ocrPages() == result.failedPages()) {
        throw new IOException("扫描页OCR识别失败,请稍后重试");
      }
      throw new IllegalArgumentException("未提取到内容,请检查文档或使用扫描PDF");
    }
    return result;
  }
}

判定顺序决定了一个重要行为:PDF 与 DOCX 走魔数判定,其余格式才看扩展名。因此把扫描版 PDF 改名为 .txt 依旧按 PDF 处理,这也是单元测试里刻意覆盖的场景。

PDF:逐页在文本与 OCR 之间选择 ​

PDF 不做整篇二选一,而是逐页判定。单页去掉空白字符后剩余不足 40 个字符、或出现替换字符 \uFFFD,就认为该页需要 OCR。页与页之间没有依赖,所以并发提取、按页码回填,正文顺序与串行时一致:

java
  /** 并发提取的页面数:OCR 是网络调用,页与页之间并发即可,不需要按 CPU 核数拉满。 */
  private static final int OCR_PAGE_PARALLELISM = Math.max(2, Math.min(4, Runtime.getRuntime().availableProcessors()));

  /** 逐页提取 PDF:文本层优先,扫描页走 OCR,页面之间并发处理但按原页序输出。 */
  private Parsed parsePdf(byte[] data, java.util.function.Consumer<Progress> progress) throws Exception {
    try (PDDocument pdf = PDDocument.load(data)) {
      if (pdf.isEncrypted()) {
        throw new IllegalArgumentException("请先解密PDF文件");
      }
      int totalPages = pdf.getNumberOfPages();
      PDFTextStripper stripper = new PDFTextStripper();
      stripper.setSortByPosition(true);
      String sourceHash = Md5Utils.md5Hex(data);
      PageResult[] results = new PageResult[totalPages];
      AtomicInteger completed = new AtomicInteger();
      ForkJoinPool pool = new ForkJoinPool(OCR_PAGE_PARALLELISM);
      try {
        List<ForkJoinTask<?>> tasks = new ArrayList<>(totalPages);
        for (int page = 1; page <= totalPages; page++) {
          int pageNumber = page;
          tasks.add(pool.submit(() -> {
            try {
              results[pageNumber - 1] = extractPdfPage(pdf, stripper, sourceHash, pageNumber);
            } catch (IOException e) {
              throw new UncheckedIOException("解析第 " + pageNumber + " 页失败", e);
            }
            if (progress != null) {
              progress.accept(new Progress(completed.incrementAndGet(), totalPages));
            }
          }));
        }
        for (ForkJoinTask<?> task : tasks) {
          task.join();
        }
      } finally {
        pool.shutdown();
      }
      StringBuilder text = new StringBuilder();
      int ocrPages = 0;
      int nativePages = 0;
      int failedPages = 0;
      for (PageResult pageResult : results) {
        if (pageResult.viaOcr()) {
          ocrPages++;
        } else {
          nativePages++;
        }
        if (pageResult.failed()) {
          failedPages++;
        }
        if (!pageResult.text().isBlank()) {
          text.append("\n\n> Page ").append(pageResult.page()).append("\n\n").append(pageResult.text());
        }
      }
      String suffix = failedPages == 0 ? "" : "-partial";
      String strategy;
      if (ocrPages == 0) {
        strategy = "pdf-text";
      } else if (nativePages == 0) {
        strategy = "pdf-ocr" + suffix;
      } else {
        strategy = "pdf-mixed" + suffix;
      }
      return new Parsed(strategy, text.toString(), totalPages, ocrPages, failedPages);
    }
  }

  /** 提取单页:先尝试文本层,文本不足时把该页单独保存后交给 OCR。 */
  private PageResult extractPdfPage(PDDocument pdf, PDFTextStripper stripper, String sourceHash, int pageNumber)
      throws IOException {
    String page;
    // PDFTextStripper 与 PDDocument 都不是线程安全的,逐页提取时串行访问。
    synchronized (pdf) {
      stripper.setStartPage(pageNumber);
      stripper.setEndPage(pageNumber);
      page = stripper.getText(pdf);
    }
    if (page.replaceAll("\\s", "").length() >= 40 && !page.contains("\uFFFD")) {
      return new PageResult(pageNumber, page, false, false);
    }
    byte[] pageData;
    try (PDDocument single = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      synchronized (pdf) {
        single.importPage(pdf.getPage(pageNumber - 1));
      }
      single.save(out);
      pageData = out.toByteArray();
    }
    // OCR 结果为空既可能是空白页,也可能是服务没能识别,两种都按空页处理并保证页码占位。
    boolean[] failed = new boolean[1];
    String[] recognized = new String[1];
    boolean ok = withOcrRetry(() -> ocrPdfPage(sourceHash, pageNumber, pageData), recognized, failed,
        sourceHash + ":page:" + pageNumber + ":" + OCR_CACHE_VERSION);
    return new PageResult(pageNumber, ok ? recognized[0] : "", true, failed[0]);
  }

要点:

  • 页序不会被并发打乱。每页的结果按页码回填数组,正文严格按物理页码顺序拼接,与串行实现的结果一致。并发只发生在页与页之间。
  • 每页都写入 > Page N 标记,让后续分段与大模型引用能定位到页。
  • 单页导出会生成新的 PDF 内部对象 ID,所以缓存键不能用导出后的字节,而要用「原始文件内容 + 页码」,见下节。
  • strategy 由「原生页数量」推导:全部原生为 pdf-text,全部走 OCR 为 pdf-ocr,其余为 pdf-mixed;有页重试后仍失败时加 -partial 后缀。
  • PDDocument 与 PDFTextStripper 都加锁,只把耗时的网络 OCR 放在锁外并发,避免共享对象被多线程同时读写。

OCR:远程任务、轮询与缓存 ​

OCR 统一走 ocrWithCache,先查缓存,未命中才提交远程任务;同一份内容并发出现时按内容加锁,后到的调用等第一次的结果落库后直接读缓存:

java
  protected String ocr(byte[] data, String filename) throws Exception {
    return ocrWithCache(data, filename, Md5Utils.md5Hex(data) + ":" + OCR_CACHE_VERSION);
  }

  protected String ocrPdfPage(String sourceHash, int page, byte[] pageData) throws Exception {
    // Serializing a PDF page can generate a fresh internal document ID. Cache by
    // original file content and page number instead of the transient PDF bytes.
    return ocrWithCache(pageData, "page.pdf", sourceHash + ":page:" + page + ":" + OCR_CACHE_VERSION);
  }

  private String ocrWithCache(byte[] data, String filename, String hash) throws Exception {
    String cached = readOcrCache(hash);
    if (cached != null && !cached.isBlank()) {
      return textOnly(cached);
    }
    synchronized (OCR_LOCK_STRIPES[Math.floorMod(hash.hashCode(), OCR_LOCKS)]) {
      // 同一份内容的第一个请求可能已经写好缓存,这里再查一次就能省掉重复的付费调用。
      cached = readOcrCache(hash);
      if (cached != null && !cached.isBlank()) {
        return textOnly(cached);
      }
      String markdown = recognize(data, filename);
      if (markdown != null && !markdown.isBlank()) {
        writeOcrCache(hash, markdown);
      }
      return markdown;
    }
  }

  protected String readOcrCache(String hash) {
    return Db.queryStr("select content from moss_kb_document_markdown_cache where id=?", hash);
  }

  protected void writeOcrCache(String hash, String markdown) {
    Db.update("insert into moss_kb_document_markdown_cache(id,content) values(?,?) "
        + "on conflict(id) do update set content=excluded.content", hash, markdown);
  }

缓存表结构极简,主键即缓存键:

sql
CREATE TABLE "public"."moss_kb_document_markdown_cache" (
  "id" VARCHAR PRIMARY KEY,
  "target" VARCHAR,
  "content" TEXT
);

空白结果不写缓存:远程 OCR 对空白页会正常返回空正文,如果把空字符串也写进缓存,这一页就再也不会被重新识别;不写缓存则下次请求会重新提交。

真实提交与轮询逻辑如下,供应商返回的是异步任务,必须轮询到终态:

java
  protected String recognize(byte[] data, String filename) throws Exception {
    GiteeClient client = new GiteeClient();
    GiteeDocumentParseRequest request = new GiteeDocumentParseRequest();
    request.setModel(OCR_MODEL);
    request.setPrompt(GiteePromptConst.pdf_to_markdown_prompt);
    request.setInclude_image(false);
    request.setInclude_image_base64(false);
    GiteeTaskResponse task = client.parseDocument(data, filename, request);
    long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MINUTES.toNanos(8);
    while (!Arrays.asList("success", "succeeded", "completed")
        .contains(String.valueOf(task.getStatus()).toLowerCase(Locale.ROOT))) {
      if (task.getStatus() == null || task.getTask_id() == null) {
        throw new IOException("OCR服务未返回有效任务状态");
      }
      if (Arrays.asList("failed", "failure", "cancelled", "canceled")
          .contains(task.getStatus().toLowerCase(Locale.ROOT))) {
        throw new IOException("OCR任务失败: " + task.getTask_id());
      }
      if (System.nanoTime() > deadline) {
        throw new IOException("OCR任务超时: " + task.getTask_id());
      }
      Thread.sleep(2000);
      task = client.getTask(task.getTask_id());
    }
    // 扫描页可能是空白页或整页图片,服务会正常返回空正文,这里按空内容处理,由调用方决定是否记录。
    String markdown = textOnly(GiteeSimpleMarkdownUtils.toMarkdown(task.getOutput(), null));
    if (markdown == null || markdown.isBlank()) {
      log.info("OCR returned no text for {}", filename);
      return "";
    }
    return markdown;
  }

  private String textOnly(String markdown) {
    if (markdown == null) {
      return "";
    }
    return markdown.replaceAll("(?is)<img\\b[^>]*>", "")
        .replaceAll("(?is)<div[^>]*>\\s*</div>", "").replaceAll("!\\[[^\\]]*\\]\\([^)]*\\)", "");
  }

设计取舍:

  • 只取正文:include_image 与 include_image_base64 都关闭,返回结果再剥掉 <img>、空 <div> 与 Markdown 图片语法,避免把 base64 图片带进分段和向量。
  • 超时 8 分钟:超时或失败都抛出带 task_id 的异常,交给重试处理。
  • 模型名使用常量:OCR_MODEL 指向 PaddleOCR-VL-1.5 而不是裸字符串,便于后续切换 OCR 模型,缓存版本号同步跟着换,旧缓存不会被复用。

OCR 失败重试 ​

远程 OCR 是外部服务,偶发失败、超时和限流都会发生。单页失败按 1 秒、2 秒退避重试,最多三次;三次都失败就记为空页并继续解析其余页面:

java
  /** 单个页面 OCR 的最大尝试次数,用于兜底偶发网络与服务端错误。 */
  private static final int OCR_MAX_ATTEMPTS = 3;

  /**
   * OCR 重试:失败时按 1s、2s 退避重试,最后一次仍失败就记为失败页。
   *
   * <p>“未识别到内容”是正常返回,不是异常,因此不在这里重试。
   *
   * @return 是否拿到结果
   */
  private boolean withOcrRetry(OcrCall call, String[] result, boolean[] failed, String describe) {
    for (int attempt = 1; attempt <= OCR_MAX_ATTEMPTS; attempt++) {
      try {
        result[0] = call.recognize();
        return true;
      } catch (Exception e) {
        log.warn("OCR attempt {}/{} failed for {}: {}", attempt, OCR_MAX_ATTEMPTS, describe, e.getMessage());
        if (attempt < OCR_MAX_ATTEMPTS) {
          try {
            Thread.sleep(1000L * attempt);
          } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            failed[0] = true;
            return false;
          }
        }
      }
    }
    log.warn("OCR gave up for {}", describe);
    failed[0] = true;
    return false;
  }

  /** 一次 OCR 调用,用于把重试逻辑包在可覆盖的 OCR 方法外面。 */
  private interface OcrCall {
    String recognize() throws Exception;
  }

“未识别到内容”与“服务失败”是两件事:空白扫描页返回空正文属于正常结果,不重试也不影响整份文档;无效任务状态、任务失败、超时属于服务问题,才进入重试。

  • 缓存键带模型后缀:md5(content) + ":" + OCR_CACHE_VERSION,换模型或换版本后旧缓存不会污染新结果。
  • 重复上传不重复计费:同一文件改名后再次解析,整篇缓存命中;PDF 的每一页也各有一条缓存,中断重试只需补齐缺失页。
  • 失败页单独记账:重试后仍失败的页计入 failedPages,策略标识加 -partial 后缀,failed_page_count 一并返回给前端,便于判断是「文档本身空白」还是「OCR 服务不稳定」。

DOCX:段落、表格与内嵌图片 ​

DOCX 不走 ZIP 集合解析,而是单独读取 word/document.xml,同时关闭 DTD 与外部实体:

java
  private static final int DOCX_XML_LIMIT = 20 * 1024 * 1024;

  private String docx(byte[] data) throws Exception {
    try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
      ZipEntry entry;
      while ((entry = zip.getNextEntry()) != null) {
        if (!entry.getName().equals("word/document.xml")) {
          continue;
        }
        byte[] xml = zip.readNBytes(DOCX_XML_LIMIT + 1);
        if (xml.length > DOCX_XML_LIMIT) {
          throw new IllegalArgumentException("DOCX内容过大");
        }
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        Document doc = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
        StringBuilder out = new StringBuilder();
        Node body = doc.getElementsByTagNameNS("*", "body").item(0);
        for (Node node = body.getFirstChild(); node != null; node = node.getNextSibling()) {
          if ("p".equals(node.getLocalName())) {
            out.append(text(node)).append("\n\n");
          }
          if ("tbl".equals(node.getLocalName())) {
            NodeList rows = ((Element) node).getElementsByTagNameNS("*", "tr");
            for (int r = 0; r < rows.getLength(); r++) {
              NodeList cells = ((Element) rows.item(r)).getElementsByTagNameNS("*", "tc");
              out.append('|');
              for (int c = 0; c < cells.getLength(); c++) {
                out.append(text(cells.item(c)).replace("|", "\\|")).append('|');
              }
              out.append('\n');
              if (r == 0) {
                out.append('|');
                for (int c = 0; c < cells.getLength(); c++) {
                  out.append("---|");
                }
                out.append('\n');
              }
            }
            out.append('\n');
          }
        }
        return out.toString();
      }
    }
    throw new IllegalArgumentException("DOCX缺少正文");
  }

  private String text(Node node) {
    StringBuilder out = new StringBuilder();
    NodeList texts = ((Element) node).getElementsByTagNameNS("*", "t");
    for (int i = 0; i < texts.getLength(); i++) {
      out.append(texts.item(i).getTextContent());
    }
    return out.toString();
  }
  • 段落转成 Markdown 段,表格转成带表头分隔线的 Markdown 表格,单元格内的 | 会转义,列数不会错位。
  • 内嵌图片走 OCR 只在正文可读字符不足 40 时触发,判断与图片收集都在 parse 分支里完成:
java
    } else if (data.length > 4 && data[0] == 'P' && data[1] == 'K' && name.endsWith(".docx")) {
      String extracted = docx(data);
      if (extracted.replaceAll("\\s", "").length() < MIN_NATIVE_CHARS) {
        String images = docxImages(data);
        result = new Parsed(images.isBlank() ? "docx-structure" : "docx-ocr", extracted + images, 0);
      } else {
        result = new Parsed("docx-structure", extracted, 0);
      }
    }

docxImages 只遍历 word/media/ 且能做图片解码的条目,最多 50 张、单张不超过 20 MB,超限直接报错而不是静默截断。

表格、旧版 DOC 与 ZIP ​

表格用 POI 的 DataFormatter 输出显示值,公式单元格取缓存计算值,避免把公式串写进正文:

java
  private String spreadsheet(byte[] data) throws Exception {
    try (org.apache.poi.ss.usermodel.Workbook workbook =
        org.apache.poi.ss.usermodel.WorkbookFactory.create(new ByteArrayInputStream(data))) {
      org.apache.poi.ss.usermodel.DataFormatter formatter =
          new org.apache.poi.ss.usermodel.DataFormatter(Locale.ROOT);
      formatter.setUseCachedValuesForFormulaCells(true);
      StringBuilder out = new StringBuilder();
      int rows = 0;
      for (org.apache.poi.ss.usermodel.Sheet sheet : workbook) {
        out.append("\n## ").append(sheet.getSheetName()).append("\n\n");
        for (org.apache.poi.ss.usermodel.Row row : sheet) {
          if (++rows > 100000 || row.getLastCellNum() > 500 || out.length() > 20 * 1024 * 1024) {
            throw new IllegalArgumentException("表格内容过大,请拆分后上传");
          }
          for (int i = 0; i < row.getLastCellNum(); i++) {
            out.append('|').append(formatter.formatCellValue(row.getCell(i))
                .replace("|", "\\|").replace('\n', ' '));
          }
          out.append("|\n");
        }
      }
      return out.toString();
    }
  }

旧版 .doc 用 WordExtractor 取纯文本:

java
    } else if (name.endsWith(".doc")) {
      try (org.apache.poi.hwpf.HWPFDocument word =
              new org.apache.poi.hwpf.HWPFDocument(new ByteArrayInputStream(data));
          org.apache.poi.hwpf.extractor.WordExtractor extractor =
              new org.apache.poi.hwpf.extractor.WordExtractor(word)) {
        result = new Parsed("doc-text", extractor.getText(), 0);
      }
    }

ZIP 只作为「文档集合」处理,递归调用同一个解析器,因此包内的 PDF、DOCX、表格都能各自按真实内容解析;包内出现 ZIP 直接拒绝:

java
  private String archive(byte[] data) throws Exception {
    StringBuilder out = new StringBuilder();
    try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(data))) {
      ZipEntry entry;
      int count = 0;
      long total = 0;
      while ((entry = zip.getNextEntry()) != null) {
        if (entry.isDirectory()) {
          continue;
        }
        String name = entry.getName().replace('\\', '/');
        if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")) {
          continue;
        }
        if (name.toLowerCase(Locale.ROOT).endsWith(".zip") || ++count > 50) {
          throw new IllegalArgumentException("ZIP最多包含50个文件,不支持嵌套ZIP");
        }
        byte[] bytes = zip.readNBytes(100 * 1024 * 1024 + 1);
        total += bytes.length;
        if (total > 100 * 1024 * 1024) {
          throw new IllegalArgumentException("ZIP解压后的内容不能超过100MB");
        }
        out.append("\n\n# ").append(name).append("\n\n").append(parse(bytes, name).text());
      }
    }
    return out.toString();
  }

每个条目都会以 # 文件名 作为一级标题插入,解析结果里保留了压缩包内的相对路径,方便用户核对来源。

文本与 HTML ​

文本类文件优先按 UTF-8 严格解码,遇到非法字节序列再回退 GB18030,覆盖旧版中文文档:

java
    } else if (name.matches(".*\\.(txt|md|log|csv|json|html|htm)$")) {
      String text;
      try {
        text = StandardCharsets.UTF_8.newDecoder()
            .decode(java.nio.ByteBuffer.wrap(data)).toString();
      } catch (java.nio.charset.CharacterCodingException e) {
        text = new String(data, java.nio.charset.Charset.forName("GB18030"));
      }
      if (name.matches(".*\\.(html|htm)$")) {
        text = org.jsoup.Jsoup.parse(text).wholeText();
      }
      result = new Parsed("text", text, 0);
    }

这里用的是 newDecoder().decode(...)(严格模式)而不是 new String(data, UTF_8),两者的区别正是回退逻辑能否生效的关键。

远端 OCR 依赖的接口形态 ​

OCR 请求对象包含模型、提示词、图片开关与截断页数:

java
package nexus.io.gitee;

public class GiteeDocumentParseRequest {
  private String model;
  private Boolean include_image;
  private Boolean include_image_base64;
  private Integer end_pages;
  private String output_format;
  private String prompt;
  // getter / setter 省略
}

客户端提供「按文件」「按字节数组」以及「图片 OCR」三种入口,本项目使用字节数组形式,从而不落地临时文件:

java
  public GiteeTaskResponse parseDocument(byte[] data, String filename, GiteeDocumentParseRequest requestEntity) {
    String suffix = FilenameUtils.getSuffix(filename);
    String contentType = ContentTypeUtils.getContentType(suffix);
    RequestBody fileBody = RequestBody.create(data, MediaType.get(contentType));
    return parseDocument(fileBody, filename, requestEntity);
  }

  public GiteeTaskResponse getTask(String taskId) {
    String url = baseUrl + "/api/v1/task/" + taskId;
    Request request = new Request.Builder().url(url)
        .addHeader("Authorization", "Bearer " + apiKey).get().build();
    return execute(request);
  }

API Key 与地址来自环境配置(GITEE_API_KEY、GITEE_BASE_URL),不写在代码里。任务响应中的 status、task_id、output、created_at、started_at、completed_at 都是轮询与排障时实际使用到的字段。

分段与解析预览 ​

解析完成后交给 MossKbDocumentSplitService 做 token 级分段,并把策略与页数一并返回给前端。分段服务同时提供同步与异步两个入口,异步入口只负责建任务并把解析交给后台线程:

java
package nexus.io.mosskb.service.kb;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;

import com.jfinal.kit.Kv;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import lombok.extern.slf4j.Slf4j;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadResult;
import nexus.io.mosskb.utils.ExecutorServiceUtils;
import nexus.io.openai.token.OpenAiTokenizer;

@Slf4j
public class MossKbDocumentSplitService {

  public ResultVo split(byte[] data, UploadResult vo) throws IOException, InterruptedException, ExecutionException {
    String filename = vo.getName();
    DocumentParsingService.Parsed parsed;
    try {
      parsed = new DocumentParsingService().parse(data, filename);
    } catch (Exception e) {
      throw new IOException(e.getMessage(), e);
    }
    return ResultVo.ok(splitParsed(parsed, filename, vo));
  }

  /**
   * 异步拆分:立刻返回 task_id,解析与分段交给后台虚拟线程,前端轮询任务状态取结果。
   *
   * <p>大文档的解析要几分钟,占着请求线程等待既容易超时,也让前端只能干等。
   */
  public Long splitAsync(byte[] data, UploadResult vo, Long userId) {
    MossKbDocumentSplitTaskService taskService = Aop.get(MossKbDocumentSplitTaskService.class);
    Long taskId = taskService.start(userId, vo, data == null ? 0L : (long) data.length);
    ExecutorServiceUtils.getDocumentParseExecutor().execute(() -> runSplit(data, vo, taskId, taskService));
    return taskId;
  }

  /** 后台解析入口:把解析进度与最终分段写回任务表,异常统一落到任务失败原因。 */
  private void runSplit(byte[] data, UploadResult vo, Long taskId, MossKbDocumentSplitTaskService taskService) {
    String filename = vo.getName();
    try {
      DocumentParsingService.Parsed parsed = new DocumentParsingService().parse(data, filename,
          (progress) -> taskService.markProgress(taskId, progress.completed(), progress.total()));
      taskService.markTotal(taskId, parsed.pages());
      taskService.markProgress(taskId, parsed.pages(), parsed.pages());
      taskService.complete(taskId, splitParsed(parsed, filename, vo));
    } catch (Exception e) {
      taskService.fail(taskId, e.getMessage());
    }
  }

  /** 把解析结果按分词器切成段落,并组装成前端提交分段需要的结构。 */
  private List<Kv> splitParsed(DocumentParsingService.Parsed parsed, String filename, UploadResult vo) {
    String markdown = parsed.text();
    List<TextSegment> segments = split(markdown);
    Kv fileSplitResult = Kv.by("name", filename).set("id", vo.getId());
    List<Kv> contents = new ArrayList<>();
    for (TextSegment textSegment : segments) {
      contents.add(Kv.by("title", "").set("content", textSegment.text()));
    }
    fileSplitResult.set("content", contents).set("parse_strategy", parsed.strategy()).set("page_count", parsed.pages());
    if (parsed.ocrPages() > 0) {
      fileSplitResult.set("ocr_page_count", parsed.ocrPages());
    }
    if (parsed.failedPages() > 0) {
      fileSplitResult.set("failed_page_count", parsed.failedPages());
    }
    return List.of(fileSplitResult);
  }

  public List<TextSegment> split(String markdown) {
    Document document = new Document(markdown);
    // 使用较大的块大小(2000)和相同的重叠(400)
    DocumentSplitter splitter = DocumentSplitters.recursive(2000, 400, new OpenAiTokenizer());
    List<TextSegment> segments = splitter.split(document);
    return segments;
  }
}

id 直接取自上传结果,parse_strategy 与 page_count 用于排查「为什么这篇文档没解析出内容」,page_count 对非 PDF 为 0;ocr_page_count 与 failed_page_count 分别表示走 OCR 的页数和重试后仍失败的页数。分段确认必须回传 id,后端据此建立文档与文件、分段的关联。

异步化的任务表、状态接口与前端轮询见文档解析异步化。

接口一:解析预览 ​

http
POST /api/dataset/document/split
Authorization: Bearer <JWT_TOKEN>
Content-Type: multipart/form-data

控制器一次接收多个文件,逐个上传并建任务,立即返回任务 ID;解析在后台进行,前端轮询状态接口取分段:

java
package nexus.io.mosskb.controller;

import java.util.ArrayList;
import java.util.List;

import com.jfinal.kit.Kv;

import nexus.io.annotation.Get;
import nexus.io.annotation.Post;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.SystemFileService;
import nexus.io.mosskb.service.kb.MossKbDocumentSplitService;
import nexus.io.mosskb.service.kb.MossKbDocumentSplitTaskService;
import nexus.io.mosskb.vo.MossKbDocumentSplitTaskVo;
import nexus.io.model.result.ResultVo;
import nexus.io.model.upload.UploadFile;
import nexus.io.model.upload.UploadResult;
import nexus.io.tio.boot.http.TioRequestContext;

@RequestPath("/api/dataset/document")
public class ApiDatasetDocumentController {

  /**
   * 分段预览:只负责上传与建任务,解析在后台线程逐页推进。
   *
   * <p>大文档解析要几分钟,请求线程不再等待,前端拿 task_id 轮询 {@link #splitTask(Long)} 取结果。
   */
  @Post("/split")
  public ResultVo split(nexus.io.tio.http.common.HttpRequest request) {
    Object[] files = request.getParams().get("file");
    if (files == null || files.length == 0) {
      return ResultVo.fail("请求体中未找到文件");
    }
    Long userId = TioRequestContext.getUserIdLong();
    List<Long> taskIds = new ArrayList<>();
    List<Kv> failures = new ArrayList<>();
    for (Object item : files) {
      if (!(item instanceof UploadFile)) {
        return ResultVo.fail("文件格式不正确");
      }
      UploadFile file = (UploadFile) item;
      UploadResult uploaded = Aop.get(SystemFileService.class).upload(file, "default", "default");
      if (uploaded == null) {
        failures.add(Kv.by("name", file.getName()).set("message", "文件保存失败"));
        continue;
      }
      try {
        taskIds.add(Aop.get(MossKbDocumentSplitService.class).splitAsync(file.getData(), uploaded, userId));
      } catch (Exception e) {
        failures.add(Kv.by("name", file.getName()).set("message", e.getMessage()));
      }
    }
    if (taskIds.isEmpty()) {
      return ResultVo.fail(failures.isEmpty() ? "没有可解析的文件" : String.valueOf(failures.get(0).get("message")));
    }
    return ResultVo.ok(Kv.by("task_id_list", taskIds).set("failures", failures));
  }

  /**
   * 分段预览任务状态。
   *
   * <p>running 时 data 为空,前端继续轮询;success 时 data 是分段结果;failed 时按失败返回,前端直接提示。
   */
  @Get("/split/task/{taskId}")
  public ResultVo splitTask(Long taskId) {
    Long userId = TioRequestContext.getUserIdLong();
    MossKbDocumentSplitTaskVo task = Aop.get(MossKbDocumentSplitTaskService.class).get(userId, taskId);
    if (task == null) {
      return ResultVo.fail("任务不存在或无权访问");
    }
    if (MossKbDocumentSplitTaskService.STATUS_FAILED.equals(task.getStatus())) {
      return ResultVo.fail(task.getErrorMessage() == null ? "文档解析失败" : task.getErrorMessage());
    }
    if (MossKbDocumentSplitTaskService.STATUS_SUCCESS.equals(task.getStatus())) {
      return ResultVo.ok(task.getResult() == null ? new ArrayList<>() : task.getResult());
    }
    return ResultVo.ok();
  }

public ResultVo split_pattern() { return ResultVo.ok(java.util.List.of( com.jfinal.kit.Kv.by("key", "\n\n").set("value", "段落"), com.jfinal.kit.Kv.by("key", "\n").set("value", "换行"))); } }


返回结构(`data` 为数组,每个元素对应一个上传文件):

```json
[
  {
    "id": "474233893756760066",
    "name": "行政复议法.docx",
    "parse_strategy": "docx-structure",
    "page_count": 0,
    "content": [
      { "title": "", "content": "第一条 为了……" }
    ]
  }
]

解析失败时后端返回的消息会作为错误给到界面,例如 文件不能超过100MB、未提取到内容,请检查文档或使用扫描PDF、扫描页OCR识别失败,请稍后重试。这些文案是给最终用户看的,不要改成内部堆栈信息。

接口二:分段确认请求 ​

http
POST /api/dataset/{datasetId}/document/_bach
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
json
[{"id":"<解析返回的文件ID>","name":"资料.docx","paragraphs":[{"title":"标题","content":"经确认的正文"}]}]

文件 ID 用于关联上传文件,知识库 ID 用于授权和选择向量模型。不要在前端确认时丢弃文件 ID。

后端控制器与请求对象:

java
package nexus.io.mosskb.controller;

import java.util.List;

import nexus.io.annotation.Post;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbParagraphSplitService;
import nexus.io.mosskb.vo.ParagraphBatchVo;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.utils.json.JsonUtils;

@RequestPath("/api/dataset")
public class ApiDatasetController {

  @Post("/{id}/document/_bach")
  public ResultVo batch(Long id, HttpRequest request) {
    String bodyString = request.getBodyString();
    Long userId = TioRequestContext.getUserIdLong();
    List<ParagraphBatchVo> list = JsonUtils.parseArray(bodyString, ParagraphBatchVo.class);
    return Aop.get(MossKbParagraphSplitService.class).batch(userId, id, list);
  }
}
java
package nexus.io.mosskb.vo;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class ParagraphBatchVo {
  private Long id;
  private String name;
  private List<Paragraph> paragraphs;
}

MossKbParagraphSplitService.batch 会用 id(上传文件 ID)加当前用户去 moss_kb_document 里反查文档:查不到就新建,查到就复用,然后写入段落、汇总句与句子级向量。这条链路见片段汇总与段落分块与检索,本节不再重复。

路径拼写提醒:该接口在前后端都是 _bach(少一个 t),与问题批量的 _batch 不同。改接口时两边要一起改,否则会出现 404。

前端接入 ​

上传校验 ​

文件类型与体积在文件选择阶段先拦一遍,后端再兜底一遍:

vue
<el-upload
  accept=".txt,.md,.log,.doc,.docx,.pdf,.html,.zip,.xlsx,.xls,.csv,.png,.jpg,.jpeg,.tif,.tiff"
/>
ts
// 上传on-change事件
const fileHandleChange = (file: any, fileList: UploadFiles) => {
  //1、判断文件大小是否合法,文件限制不能大于100M
  const isLimit = file?.size / 1024 / 1024 < 100
  if (!isLimit) {
    MsgError(t('views.document.upload.errorMessage1'))
    fileList.splice(-1, 1) //移除当前超出大小的文件
    return false
  }

  if (!isRightType(file?.name, form.value.fileType)) {
    if (file?.name !== '.DS_Store') {
      MsgError(t('views.document.upload.errorMessage2'))
    }
    fileList.splice(-1, 1)
    return false
  }
  if (file?.size === 0) {
    MsgError(t('views.document.upload.errorMessage3'))
    fileList.splice(-1, 1)
    return false
  }
}

触发解析预览 ​

前端把选中的多个文件都塞进同一个 file 字段(同名多值),后端才能一次为整批文件建任务:

ts
import { Result } from '@/request/Result'
import { get, post, del, put, exportExcel, exportFile } from '@/request/index'
import type { Ref } from 'vue'
import type { KeyValue } from '@/api/type/common'
import type { pageRequest } from '@/api/type/common'

const prefix = '/dataset'

/**
 * 分段预览(上传文档)
 *
 * 后端只负责保存文件并建解析任务,立即返回 { task_id_list };分段结果由本方法内部轮询
 * /dataset/document/split/task/{taskId} 取得,调用方拿到的仍然是分段列表。
 */
const postSplitDocument: (data: any) => Promise<Result<any>> = async (data) => {
  const started: any = await post(
    `${prefix}/document/split`,
    data,
    undefined,
    undefined,
    1000 * 60 * 10
  )
  const taskIdList: Array<string> = started?.data?.task_id_list || []
  if (!taskIdList.length) {
    return started
  }
  const lists = await Promise.all(taskIdList.map((taskId) => pollSplitTask(taskId)))
  return { ...started, data: lists.flat() } as Result<any>
}

/** 解析一份大文档要几分钟,所以按 2 秒间隔轮询任务状态,最多等待 60 分钟。 */
const pollSplitTask: (taskId: string) => Promise<Array<any>> = async (taskId) => {
  const deadline = Date.now() + 1000 * 60 * 60
  for (;;) {
    try {
      const res: any = await get(`${prefix}/document/split/task/${taskId}`, {}, undefined, {
        silent: true
      })
      if (Array.isArray(res?.data)) {
        return res.data
      }
    } catch (error: any) {
      throw new Error(error?.message || '文档解析失败')
    }
    if (Date.now() > deadline) {
      throw new Error('文档解析超时,请稍后在文档列表查看解析结果')
    }
    await new Promise((resolve) => setTimeout(resolve, 2000))
  }
}

/**
 * 分段标识列表
 */
const listSplitPattern: (
  loading?: Ref<boolean>
) => Promise<Result<Array<KeyValue<string, string>>>> = (loading) => {
  return get(`${prefix}/document/split_pattern`, {}, loading)
}
ts
function splitDocument() {
  loading.value = true
  let fd = new FormData()
  documentsFiles.value.forEach((item) => {
    if (item?.raw) {
      fd.append('file', item?.raw)
    }
  })
  if (radio.value === '2') {
    Object.keys(form).forEach((key) => {
      if (key == 'patterns') {
        form.patterns.forEach((item) => fd.append('patterns', item))
      } else {
        fd.append(key, form[key])
      }
    })
  }
  documentApi
    .postSplitDocument(fd)
    .then((res: any) => {
      const list = res.data
      list.map((item: any) => {
        if (item.name.length > 128) {
          item.name = cutFilename(item.name, 128)
        }
        if (checkedConnect.value) {
          item.content.map((v: any) => {
            v['problem_list'] = v.title.trim() ? [{ content: v.title.trim() }] : []
          })
        }
      })
      paragraphList.value = list
      loading.value = false
    })
    .catch(() => {
      loading.value = false
    })
}

上传本身的超时放宽到 10 分钟(1000 * 60 * 10),它现在只包含文件传输与建任务,正常在秒级完成;真正的解析时长由轮询的 1 小时上限约束,不再占用请求连接。轮询请求带 silent: true,任务失败时接口返回的就是给用户看的失败原因,请求层已经提示过,轮询循环不重复提示。

当前实现说明:请求里的 patterns、limit、with_filter 属于高级分段参数,前端会随表单一起提交,但后端解析接口并未读取它们——分段固定为 2000 token / 400 token 重叠。文档中不要把这些参数描述成生效中的能力。

预览与提交 ​

预览组件按文件分标签页,content.length 就是分段数:

vue
<el-tabs v-model="activeName" class="paragraph-tabs">
  <template v-for="(item, index) in data" :key="index">
    <el-tab-pane :label="item.name" :name="index">
      <div class="mb-16">
        <el-text type="info">{{ item.content.length }} {{ $t('views.paragraph.title') }}</el-text>
      </div>
      <ParagraphList v-model="item.content" :isConnect="isConnect" />
    </el-tab-pane>
  </template>
</el-tabs>

提交时必须把解析返回的 id、name 和用户确认后的 content 一起回传:

ts
import { ref, computed, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SetRules from './component/SetRules.vue'
import ResultSuccess from './component/ResultSuccess.vue'
import UploadComponent from './component/UploadComponent.vue'
import documentApi from '@/api/document'
import { MsgConfirm, MsgSuccess } from '@/utils/message'
import { t } from '@/locales'
import useStore from '@/stores'

const { dataset, document } = useStore()

function submit() {
  loading.value = true
  const documents = [] as any
  SetRulesRef.value?.paragraphList.map((item: any) => {
    if (!SetRulesRef.value?.checkedConnect) {
      item.content.map((v: any) => {
        delete v['problem_list']
      })
    }
    documents.push({
      id: item.id,
      name: item.name,
      paragraphs: item.content
    })
  })

  if (id) {
    // 上传文档
    document
      .asyncPostDocument(id as string, documents)
      .then(() => {
        MsgSuccess(t('common.submitSuccess'))
        clearStore()
        router.push({ path: `/dataset/${id}/document` })
      })
      .catch(() => {
        loading.value = false
      })
  }
}

提交接口本身是长耗时操作(要建文档、写段落、生成汇总与句子向量),前端超时同样放宽到 5 分钟:

ts
const postDocument: (
  dataset_id: string,
  data: any,
  loading?: Ref<boolean>
) => Promise<Result<any>> = (dataset_id, data, loading) => {
  return post(`${prefix}/${dataset_id}/document/_bach`, data, {}, loading, 1000 * 60 * 5)
}

三个易错点:

  1. id 不能丢。丢了之后后端 Db.queryLong(sqlDocumentId, userId, fileId) 查不到文档,只能新建,最终表现为重复文档或段落挂错文档。
  2. name 要保留真实文件名。后端用它的后缀写入 moss_kb_document.type,也是页面展示名。
  3. 用户可编辑 content。预览阶段的段落允许增删改,提交的是编辑后的结果,不是解析原文。

测试覆盖 ​

解析器的关键分支都有单元测试,且普通测试不打付费接口:测试通过覆写 ocr / ocrPdfPage / readOcrCache / writeOcrCache / recognize 来替换远端调用。

java
package nexus.io.mosskb.service.kb;

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.junit.Test;
import static org.junit.Assert.*;

public class DocumentParsingServiceTest {
  private static class Parser extends DocumentParsingService {
    int ocrCalls;
    @Override
    protected String ocr(byte[] data, String filename) {
      ocrCalls++;
      return "Scanned content " + ocrCalls;
    }
    @Override
    protected String ocrPdfPage(String sourceHash, int page, byte[] data) {
      return ocr(data, "page.pdf");
    }
  }

  @Test
  public void pdfUsesTextAndOcrPerPageInOriginalOrder() throws Exception {
    try (PDDocument pdf = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      PDPage first = new PDPage();
      pdf.addPage(first);
      try (PDPageContentStream canvas = new PDPageContentStream(pdf, first)) {
        canvas.beginText();
        canvas.setFont(PDType1Font.HELVETICA, 12);
        canvas.newLineAtOffset(40, 700);
        canvas.showText("Native administrative review document with sufficient readable text for extraction.");
        canvas.endText();
      }
      pdf.addPage(new PDPage());
      pdf.save(out);
      Parser parser = new Parser();
      DocumentParsingService.Parsed result = parser.parse(out.toByteArray(), "misleading.txt");
      assertEquals("pdf-mixed", result.strategy());
      assertEquals(2, result.pages());
      assertEquals(1, parser.ocrCalls);
      assertTrue(result.text().indexOf("Native administrative") < result.text().indexOf("Scanned content"));
    }
  }
}

这条用例同时锁定了三件事:内容优先于扩展名(.txt 的扫描混合 PDF 仍解析为 pdf-mixed)、页序不乱(原生页文本出现在 OCR 文本之前)、只对需要 OCR 的页发起调用(ocrCalls == 1)。

缓存复用也有独立用例,模拟两次上传同一份 PDF 但文件名不同:

java
  @Test
  public void repeatedPdfUploadReusesPageCacheDespitePdfSerializationIds() throws Exception {
    class CachedParser extends DocumentParsingService {
      final java.util.Map<String, String> cache = new java.util.HashMap<>();
      final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
      @Override
      protected String readOcrCache(String key) {
        synchronized (cache) {
          return cache.get(key);
        }
      }
      @Override
      protected void writeOcrCache(String key, String text) {
        synchronized (cache) {
          cache.put(key, text);
        }
      }
      @Override
      protected String recognize(byte[] data, String filename) {
        return "Scanned page content " + calls.incrementAndGet();
      }
    }
    try (PDDocument pdf = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      pdf.addPage(new PDPage());
      pdf.addPage(new PDPage());
      pdf.save(out);
      CachedParser parser = new CachedParser();
      DocumentParsingService.Parsed first = parser.parse(out.toByteArray(), "first.pdf");
      int callsAfterFirstUpload = parser.calls.get();
      DocumentParsingService.Parsed second = parser.parse(out.toByteArray(), "renamed.pdf");
      assertEquals(first.text(), second.text());
      // 缓存键是“原文件内容 + 页码”,与序列化单页产生的临时文档 ID 无关:
      // 同一份文件第二次上传必须全部命中缓存,不能再调 OCR。
      assertEquals("重复上传时不应再次调用 OCR", callsAfterFirstUpload, parser.calls.get());
      assertTrue(parser.cache.size() >= 1);
    }
  }

异步化之后新增的用例覆盖空白页、重试与进度:

java
  /** 空白扫描页返回空正文属于正常结果,不能让它抛异常作废整份文档。 */
  @Test
  public void blankScannedPageDoesNotFailTheWholeDocument() throws Exception {
    class BlankPageParser extends DocumentParsingService {
      final java.util.List<Integer> pages = new java.util.ArrayList<>();
      @Override
      protected String readOcrCache(String key) {
        return null;
      }
      @Override
      protected void writeOcrCache(String key, String text) {
      }
      @Override
      protected String ocrPdfPage(String sourceHash, int page, byte[] pageData) {
        pages.add(page);
        // 第 2 页是空白页,其余页有正文,模拟扫描件里夹杂的空白页。
        return page == 2 ? "" : "第" + page + "页正文";
      }
    }
    BlankPageParser parser = new BlankPageParser();
    DocumentParsingService.Parsed result = parser.parse(blankPdf(3), "scan.pdf");
    assertEquals("pdf-ocr", result.strategy());
    assertEquals(3, result.ocrPages());
    assertEquals(0, result.failedPages());
    assertTrue(result.text().contains("第1页正文"));
    assertTrue(result.text().contains("第3页正文"));
    assertFalse(result.text().contains("第2页正文"));
    // 正文按物理页顺序输出。
    assertTrue(result.text().indexOf("第1页正文") < result.text().indexOf("第3页正文"));
  }

这条用例同时说明了两件事:空白页不再作废整份文档,以及并发提取后正文仍按物理页顺序拼接。

结论 ​

多文档解析的核心是先看内容再选策略:PDF 与 DOCX 靠魔数判定,PDF 逐页在文本提取与 OCR 之间切换,DOCX 在结构提取与内嵌图片 OCR 之间切换,表格与旧版 Word 各走独立通道,ZIP 只作为受限的文档集合递归解析。所有远程 OCR 结果按「内容 MD5 + 模型」或「原始文件 + 页码」缓存,空白页按空页处理,真正的服务失败按退避重试并单独记账。

调度上分成两段:上传接口只保存文件并建任务,解析在后台虚拟线程里逐页推进,前端轮询任务状态取分段。这样长耗时不再占用请求连接,单页失败也不会作废整份文档。

落地时按顺序核对:上传阶段前后端各拦一次 100 MB,并确认 http.multipart.max-request-size 写在本地配置文件里;解析阶段确认 parse_strategy 与 failed_page_count 符合预期;预览阶段确认分段数与页序正常;提交阶段确认 id 与 name 原样回传。这样从上传到入库的链路就完整了。

MIT Licensed