多文档解析
当前实现与接口说明
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 | 逐页选择提取或 OCR | pdf-mixed |
| 有页面重试后仍然 OCR 失败 | 同上,标识加后缀,失败页内容为空 | pdf-ocr-partial、pdf-mixed-partial |
| DOCX 正文与表格 | 提取段落、表格结构 | docx-structure |
| DOCX 正文极少但含图片 | 提取内嵌图片并 OCR | docx-ocr |
| DOC | 提取正文 | doc-text |
| XLS、XLSX | 按工作表和行提取显示值 | spreadsheet-structure |
| TXT、Markdown、日志、CSV、JSON、HTML | 文本解码,HTML 提取可见正文 | text |
| PNG、JPEG、TIFF 等可识别图片 | OCR | image-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-,各页可读字符不足 | 逐页渲染后远程 OCR | pdf-ocr |
| 混合型 PDF | 头部 %PDF-,部分页有文本 | 逐页在文本提取与 OCR 之间选择 | pdf-mixed |
| DOCX | ZIP 头 PK 且文件名以 .docx 结尾 | 解析 word/document.xml 的段落与表格 | docx-structure |
| 图片型 DOCX | 同上,但正文可读字符不足 40 且含 word/media/ 图片 | 结构文本 + 内嵌图片 OCR | docx-ocr |
| XLS / XLSX | 文件名后缀 | 按工作表逐行导出为 Markdown 表格 | spreadsheet-structure |
| DOC | 文件名后缀 | WordExtractor 提取纯文本 | doc-text |
| ZIP | 文件名后缀 | 逐条目递归调用同一解析器 | zip-documents |
| TXT / MD / LOG / CSV / JSON | 文件名后缀 | UTF-8 解码,失败回退 GB18030 | text |
| HTML / HTM | 文件名后缀 | jsoup 去标签取正文 | text |
| PNG / JPG / JPEG / TIFF 等 | ImageIO.read 可解码 | 直接送入远程 OCR | image-ocr |
容量与安全边界
| 限制项 | 阈值 | 触发结果 |
|---|---|---|
| HTTP 请求体 | http.multipart.max-request-size,默认部署写 105 MB | 连接在解析请求头阶段被断开,日志为 Request body exceeds the configured limit |
| 单个上传文件 | 100 MB(前后端各校验一次) | 文件不能超过100MB |
| ZIP 内文件个数 | 50 个,且不允许嵌套 ZIP | ZIP最多包含50个文件,不支持嵌套ZIP |
| ZIP 解压后总大小 | 100 MB | ZIP解压后的内容不能超过100MB |
| DOCX 内嵌图片 | 50 张,单张 20 MB | DOCX内嵌图片过多或过大 |
| 表格 | 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 统计的记录对象;需要观察进度时用带回调的重载:
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。页与页之间没有依赖,所以并发提取、按页码回填,正文顺序与串行时一致:
/** 并发提取的页面数: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,先查缓存,未命中才提交远程任务;同一份内容并发出现时按内容加锁,后到的调用等第一次的结果落库后直接读缓存:
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);
}
缓存表结构极简,主键即缓存键:
CREATE TABLE "public"."moss_kb_document_markdown_cache" (
"id" VARCHAR PRIMARY KEY,
"target" VARCHAR,
"content" TEXT
);
空白结果不写缓存:远程 OCR 对空白页会正常返回空正文,如果把空字符串也写进缓存,这一页就再也不会被重新识别;不写缓存则下次请求会重新提交。
真实提交与轮询逻辑如下,供应商返回的是异步任务,必须轮询到终态:
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 秒退避重试,最多三次;三次都失败就记为空页并继续解析其余页面:
/** 单个页面 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 与外部实体:
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分支里完成:
} 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 输出显示值,公式单元格取缓存计算值,避免把公式串写进正文:
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 取纯文本:
} 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 直接拒绝:
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,覆盖旧版中文文档:
} 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 请求对象包含模型、提示词、图片开关与截断页数:
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」三种入口,本项目使用字节数组形式,从而不落地临时文件:
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 级分段,并把策略与页数一并返回给前端。分段服务同时提供同步与异步两个入口,异步入口只负责建任务并把解析交给后台线程:
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,后端据此建立文档与文件、分段的关联。
异步化的任务表、状态接口与前端轮询见文档解析异步化。
接口一:解析预览
POST /api/dataset/document/split
Authorization: Bearer <JWT_TOKEN>
Content-Type: multipart/form-data
控制器一次接收多个文件,逐个上传并建任务,立即返回任务 ID;解析在后台进行,前端轮询状态接口取分段:
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识别失败,请稍后重试。这些文案是给最终用户看的,不要改成内部堆栈信息。
接口二:分段确认请求
POST /api/dataset/{datasetId}/document/_bach
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
[{"id":"<解析返回的文件ID>","name":"资料.docx","paragraphs":[{"title":"标题","content":"经确认的正文"}]}]
文件 ID 用于关联上传文件,知识库 ID 用于授权和选择向量模型。不要在前端确认时丢弃文件 ID。
后端控制器与请求对象:
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);
}
}
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。
前端接入
上传校验
文件类型与体积在文件选择阶段先拦一遍,后端再兜底一遍:
<el-upload
accept=".txt,.md,.log,.doc,.docx,.pdf,.html,.zip,.xlsx,.xls,.csv,.png,.jpg,.jpeg,.tif,.tiff"
/>
// 上传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 字段(同名多值),后端才能一次为整批文件建任务:
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)
}
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 就是分段数:
<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 一起回传:
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 分钟:
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)
}
三个易错点:
id不能丢。丢了之后后端Db.queryLong(sqlDocumentId, userId, fileId)查不到文档,只能新建,最终表现为重复文档或段落挂错文档。name要保留真实文件名。后端用它的后缀写入moss_kb_document.type,也是页面展示名。- 用户可编辑
content。预览阶段的段落允许增删改,提交的是编辑后的结果,不是解析原文。
测试覆盖
解析器的关键分支都有单元测试,且普通测试不打付费接口:测试通过覆写 ocr / ocrPdfPage / readOcrCache / writeOcrCache / recognize 来替换远端调用。
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 但文件名不同:
@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);
}
}
异步化之后新增的用例覆盖空白页、重试与进度:
/** 空白扫描页返回空正文属于正常结果,不能让它抛异常作废整份文档。 */
@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 原样回传。这样从上传到入库的链路就完整了。
