简介:面向Java开发者的Word文档解析与数据库入库示例工程,围绕使用Apache POI读取文档内容、提取表格数据并通过JDBC写入数据库展开,适合需要了解文件读写、文档解析、预编译语句与异常处理的中级学习者参考,也可用于企业数据录入场景的练手。压缩包共41个文件,以jar依赖包、java源码和class编译文件为主,辅以工程配置与连接属性文件,整体约13.24MB,目录结构清晰,便于搭建本地练习环境并对照工程结构理解各模块职责。已有5138人学习,说明该案例在同类需求中具有一定参考价值。源码覆盖文档打开、表格遍历、数据转换、数据库连接与批量插入等环节,配合依赖包和配置文件,可帮助读者梳理从文档读取到入库的完整链路,并借鉴其中的错误处理、事务管理以及数据完整性保障思路。
1. 从一次“上传成功但内容全是空白”说起:这套 Java 读 Word 入库方案到底解决什么问题
前阵子帮一个做内部资料归档的团队排查问题:页面上传 .docx 提示成功,数据库里也有一条记录,点开详情正文却是空的。查下来文件接收层没问题,问题出在解析层只读了document.getParagraphs(),而那份文档的正文全在表格里。Java 读取 Word 文件并上传到数据库这件事,看起来只是“读文本、写库”两步,真正落地时却会遇到格式兼容、表格嵌套、图片附件、大文件内存和重复上传一堆事。这个项目要解决的就是把 Word 里的段落、表格、图片等结构稳定拆出来,再按业务需要落到数据库,适合做合同归档、简历解析、报告采集、后台批量导入的开发者。它不追求花哨,核心是把上传、解析、清洗、入库这条链路跑通,并且能应对 .doc 老格式和几十兆的大文件。
2. 技术选型与工程骨架:Spring Boot + Apache POI 怎么搭出可跑的最小闭环
这个项目的技术栈不复杂,常见组合是 Spring Boot 的 Web 层接文件上传,Apache POI 做 Word 解析,持久层用 MyBatis 或 JdbcTemplate,数据库选 MySQL。选 POI 而不是别的库,主要原因是它对 .docx 和 .doc 都有现成支持:poi-ooxml里的XWPFDocument处理 .docx,poi-scratchpad里的HWPFDocument处理 .doc。搜索 JAVA Input Word 相关方案时,经常能看到有人把 Word 当纯文本读,结果表格和页眉全丢,后面再补就得很痛苦。工程骨架我一般按“入口薄、解析独立、落库集中”三层来拆,这样换解析器或换存储时不用动 Controller。
2.1 依赖与目录结构:把上传、解析、入库拆成三层
先看依赖。下面这份 pom 片段不写具体版本号,交给 Spring Boot 的 BOM 或项目父 pom 统一管理,避免不同项目里版本打架。poi-ooxml是解析 .docx 的主力,poi-scratchpad负责 .doc 老格式,spring-boot-starter-jdbc提供JdbcTemplate,数据库驱动按你实际用的 MySQL 版本引入。
<!-- pom.xml 片段:Word 解析与上传所需依赖 --> <dependencies> <!-- Web 层,提供 MultipartFile 文件上传能力 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JdbcTemplate,示例里用它减少 MyBatis 配置 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- .docx 解析:XWPFDocument、XWPFTable 等 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> </dependency> <!-- .doc 老格式解析:HWPFDocument、WordExtractor --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> </dependency> <!-- MySQL 驱动,scope 用 runtime 即可 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>这段依赖的关键点是poi-ooxml和poi-scratchpad各管一段格式,不要只引前者就以为能读 .doc。poi-ooxml还会带进xmlbeans等传递依赖,项目里如果已经有别的 XML 库,注意排除冲突。JdbcTemplate不是唯一选择,换成 MyBatis 也可以,示例用它是为了让落库代码短一些,读起来不被 Mapper XML 打断。
目录结构按职责拆开,后面加 PDF、Excel 解析时不用推翻重来:
src/main/java/com/example/wordingest ├── controller │ └── WordUploadController.java # 只做参数校验和响应封装 ├── service │ ├── WordIngestService.java # 编排:落原始记录 -> 解析 -> 落块 -> 更新状态 │ └── parser │ ├── WordParser.java # 解析器接口 │ ├── DocxWordParser.java # .docx 实现 │ └── DocWordParser.java # .doc 实现 ├── repository │ └── WordDocumentRepository.java # 所有 SQL 集中在这里 ├── model │ ├── WordBlock.java # 段落/表格块 │ └── ParsedWord.java # 解析结果 └── config └── AsyncConfig.java # 大文件异步线程池controller不碰 POI,parser不碰数据库,repository不关心文件格式,这条边界划清之后,解析失败时排查范围会小很多。目录名和包名可以按公司规范改,但分层建议保留。
2.2 数据库表设计:正文、段落、附件该分几张表
表设计直接决定后面查询好不好写。我的习惯是主表存文件元信息和全文,块表存段落/表格结构,附件表存图片路径。主表的content_text用LONGTEXT,方便列表页做摘要和全文检索;块表保留原始顺序,方便详情页按段落还原。md5加唯一键做去重,避免同一份文件反复上传把库撑大。
-- 主表:一次上传对应一条记录 CREATE TABLE word_document ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_name VARCHAR(255) NOT NULL COMMENT '原始文件名', file_ext VARCHAR(16) NOT NULL COMMENT 'doc 或 docx', file_size BIGINT NOT NULL COMMENT '字节数', md5 CHAR(32) NOT NULL COMMENT '文件内容摘要,用于去重', storage_path VARCHAR(500) DEFAULT NULL COMMENT '原始文件落盘路径', content_text LONGTEXT COMMENT '解析出的全文,便于检索和预览', paragraph_count INT NOT NULL DEFAULT 0, table_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待解析 1成功 2失败', error_msg VARCHAR(1000) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_md5 (md5), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 块表:保留段落和表格的原始顺序 CREATE TABLE word_block ( id BIGINT PRIMARY KEY AUTO_INCREMENT, document_id BIGINT NOT NULL, block_type VARCHAR(16) NOT NULL COMMENT 'PARAGRAPH 或 TABLE', block_index INT NOT NULL COMMENT '文档内顺序,从 0 开始', content TEXT COMMENT '段落文本或表格文本', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_document (document_id, block_index) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 附件表:图片等二进制不进主表 CREATE TABLE word_attachment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, document_id BIGINT NOT NULL, file_key VARCHAR(500) NOT NULL COMMENT '对象存储 key 或本地相对路径', content_type VARCHAR(100) DEFAULT NULL, file_size BIGINT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_document (document_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;主表里的status是个小状态机:插入时是 0,解析成功改 1,失败改 2 并把异常摘要写进error_msg。这样做的好处是上传接口可以快速返回,解析失败也能在后台看到是哪一步挂的。content_text和word_block.content有冗余,但换来的是查询方便:列表页只查主表,详情页再按document_id捞块。字符集统一utf8mb4,不然 Word 里的特殊符号和 emoji 插入时会报错。
2.3 配置项与启动入口:文件大小、临时目录、事务边界
配置文件里最容易被忽略的是上传大小限制。Spring Boot 默认的 multipart 限制偏小,几十兆的 Word 会直接抛MaxUploadSizeExceededException。下面这份配置把单文件和单请求都放宽,同时指定数据库连接和临时目录。临时目录建议放在数据盘,不要放系统盘,异步解析时文件要落在这里。
# application.yml spring: servlet: multipart: max-file-size: 50MB # 单文件上限,按业务调整 max-request-size: 60MB # 一次请求总上限 location: /data/tmp/upload # 临时文件目录,确保有写权限 datasource: url: jdbc:mysql://localhost:3306/word_ingest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: change_me hikari: maximum-pool-size: 10 word: ingest: storage-dir: /data/word/origin # 原始文件落盘目录 max-text-length: 2000000 # 防止异常文档撑爆内存max-file-size和max-request-size要一起改,只改前者时多文件上传仍可能被拦。location不配也可以,但生产环境最好显式指定,避免临时目录被清理策略误删。事务边界放在WordIngestService的落库方法上,解析过程不要包在一个大事务里,POI 解析耗时几秒到几十秒,长事务会占着连接不放,并发一上来连接池就见底。
3. 核心链路拆解:一个 .docx 从 MultipartFile 到数据库记录的完整代码
整条链路可以概括成六步:接收文件、算摘要、插主表待解析、按扩展名选解析器、批量插块、更新主表状态。每一步都要能单独失败、单独重试,不要把六步揉成一个方法。下面按代码顺序拆,先看入口,再看解析,最后看落库和附件。
3.1 文件接收与类型校验:别让 .doc 伪装成 .docx
Controller 只做三件事:判空、判扩展名、把活交给 Service。扩展名从原始文件名里截,统一转小写。更严谨的做法是再读文件头的 magic number,.docx 本质是 zip,开头是PK,.doc 是 OLE 复合文档,开头是D0 CF 11 E0。示例里先做扩展名校验,后面在解析器里再兜底。
@RestController @RequestMapping("/api/word") public class WordUploadController { private final WordIngestService ingestService; public WordUploadController(WordIngestService ingestService) { this.ingestService = ingestService; } @PostMapping("/upload") public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException { // 1. 空文件直接拒绝,避免后续无意义解析 if (file.isEmpty()) { return Map.of("code", 400, "msg", "文件为空"); } // 2. 从原始文件名取扩展名,统一小写 String original = file.getOriginalFilename(); String ext = ""; if (original != null && original.lastIndexOf('.') > -1) { ext = original.substring(original.lastIndexOf('.') + 1).toLowerCase(); } // 3. 只放行 doc 和 docx,其他格式走别的解析器 if (!Set.of("doc", "docx").contains(ext)) { return Map.of("code", 400, "msg", "只支持 doc/docx"); } Long id = ingestService.ingest(file, ext); return Map.of("code", 0, "id", id); } }@RequestParam("file")里的file要和前端form-data的字段名一致,不一致会直接 400。file.getOriginalFilename()在部分浏览器里可能带路径,截扩展名前先取最后一个点,不要用split("."),点号在正则里是通配符,容易翻车。返回体里只给id,解析状态让前端拿id去查,避免上传接口等太久。
3.2 用 XWPFDocument 解析段落与表格:逐段读、逐表读
.docx 的解析核心是XWPFDocument。它把整个 docx 包读进内存,getParagraphs()只返回 body 里的顶层段落,表格里的段落不在其中,所以表格必须单独遍历getTables()。表格单元格里还可能有多个段落,cell.getText()会把它们拼起来,遇到合并单元格会重复读,这些细节后面在避坑章展开。
@Component public class DocxWordParser implements WordParser { @Override public ParsedWord parse(byte[] bytes) throws IOException { List<WordBlock> blocks = new ArrayList<>(); StringBuilder fullText = new StringBuilder(); int index = 0; try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes))) { // 1. 先读 body 顶层段落 for (XWPFParagraph paragraph : doc.getParagraphs()) { String text = clean(paragraph.getText()); if (text.isEmpty()) { continue; } blocks.add(new WordBlock("PARAGRAPH", index++, text)); fullText.append(text).append('\n'); } // 2. 再读表格,逐行逐单元格拼接 for (XWPFTable table : doc.getTables()) { StringBuilder tableText = new StringBuilder(); for (XWPFTableRow row : table.getRows()) { List<String> cells = new ArrayList<>(); for (XWPFTableCell cell : row.getTableCells()) { // 单元格内可能有多个段落,getText 会拼接 cells.add(clean(cell.getText()).replace("\n", " ")); } tableText.append(String.join(" | ", cells)).append('\n'); } if (tableText.length() > 0) { blocks.add(new WordBlock("TABLE", index++, tableText.toString())); fullText.append(tableText); } } // 3. 页眉页脚按需读,很多归档场景需要 if (doc.getHeaderFooterPolicy() != null) { XWPFHeader header = doc.getHeaderFooterPolicy().getDefaultHeader(); if (header != null) { String headerText = clean(header.getText()); if (!headerText.isEmpty()) { blocks.add(new WordBlock("PARAGRAPH", index++, "[页眉] " + headerText)); } } } } return new ParsedWord(fullText.toString(), blocks); } }parse的入参是byte[],不是MultipartFile,这样解析器不依赖 Web 层,单元测试直接传字节就行。clean是后文会写的文本清洗方法。index从 0 开始递增,保证块表里的顺序和文档顺序一致;页眉块用[页眉]前缀标记,方便前端区分。XWPFDocument实现了Closeable,必须放在 try-with-resources 里,否则底层 zip 包句柄泄漏,跑一段时间就会出现“文件被占用”或句柄耗尽。
3.3 解析结果落库:批量插入与事务控制
落库分两步:先插主表拿自增 id,再批量插块表。块表用JdbcTemplate.batchUpdate,每批 500 条,避免一次性拼几万条 SQL。主表状态更新放在解析成功之后,失败时走markFailed,不要把异常吞掉。
@Repository public class WordDocumentRepository { private final JdbcTemplate jdbc; public WordDocumentRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } // 插入待解析记录,返回自增主键 public Long insertPending(String fileName, String ext, long size, String md5, String storagePath) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement( "INSERT INTO word_document(file_name,file_ext,file_size,md5,storage_path,status) " + "VALUES(?,?,?,?,?,0)", Statement.RETURN_GENERATED_KEYS); ps.setString(1, fileName); ps.setString(2, ext); ps.setLong(3, size); ps.setString(4, md5); ps.setString(5, storagePath); return ps; }, keyHolder); return keyHolder.getKey().longValue(); } // 批量插入块,batchSize 控制每次提交条数 @Transactional public void saveBlocks(Long documentId, List<WordBlock> blocks) { String sql = "INSERT INTO word_block(document_id,block_type,block_index,content) VALUES(?,?,?,?)"; jdbc.batchUpdate(sql, blocks, 500, (ps, block) -> { ps.setLong(1, documentId); ps.setString(2, block.getType()); ps.setInt(3, block.getIndex()); ps.setString(4, block.getContent()); }); } public void markSuccess(Long id, String text, int paragraphCount, int tableCount) { jdbc.update("UPDATE word_document SET content_text=?,paragraph_count=?,table_count=?,status=1,error_msg=NULL WHERE id=?", text, paragraphCount, tableCount, id); } public void markFailed(Long id, String errorMsg) { jdbc.update("UPDATE word_document SET status=2,error_msg=? WHERE id=?", errorMsg == null ? "unknown" : errorMsg.substring(0, Math.min(errorMsg.length(), 900)), id); } }batchUpdate的第三个参数 500 是批大小,太小网络往返多,太大事务日志压力大,500 到 1000 是比较稳的区间。markFailed里对error_msg做了截断,表字段是 1000,异常堆栈可能几万字符,不截断会二次报错。saveBlocks加@Transactional,块插一半失败会回滚,但主表的待解析记录还在,状态会停在 0,后面可以用定时任务扫status=0且超时的记录重试。
3.4 图片与附件处理:二进制不进主表
docx 里的图片在getAllPictures()里,返回XWPFPictureData,getData()是图片字节,getFileName()是包内文件名。图片不要塞进word_block.content,也不要用 BLOB 放主表,主表会被撑得很大,备份和查询都难受。常见做法是落本地目录或对象存储,数据库只存 key。
// 在 DocxWordParser 解析完文本后追加附件处理 List<XWPFPictureData> pictures = doc.getAllPictures(); List<WordAttachment> attachments = new ArrayList<>(); for (XWPFPictureData picture : pictures) { // key 用 documentId 前缀,避免同名覆盖 String key = "word/" + documentId + "/" + picture.getFileName(); // storageService.put(key, picture.getData()); 真实项目里替换成对象存储 attachments.add(new WordAttachment( key, picture.getPackagePart().getContentType(), (long) picture.getData().length )); } repository.saveAttachments(documentId, attachments);getFileName()在同一个 docx 里可能重复,比如多张图都叫image1.png,所以 key 里拼了documentId。getContentType()返回image/png这类 MIME,前端展示时用得上。图片字节在解析阶段已经全在内存里,如果文档图片特别多,落盘要改成流式写,不要把所有byte[]攒在 List 里再统一写。
4. 兼容 .doc 与异常文本:老格式、乱码、超大文件怎么处理
实际归档场景里不可能只有 .docx,总有人扔 .doc 过来,也总有文档带一堆特殊字符。这一章处理三类问题:老格式兼容、文本清洗、大文件内存。每一类都有取舍,没有银弹,关键是知道边界在哪,别等上线后才发现。
4.1 HWPFDocument 与 WordExtractor 的取舍
.doc 是二进制 OLE 格式,POI 用HWPFDocument读。它能拿全文、段落、甚至部分表格,但 API 比 XWPF 弱很多,表格结构不稳定,图片处理也麻烦。简单场景用WordExtractor.getText()拿纯文本就够了,复杂结构建议先转成 .docx 再走DocxWordParser,不要硬啃 HWPF 的表格 API。
@Component public class DocWordParser implements WordParser { @Override public ParsedWord parse(byte[] bytes) throws IOException { try (HWPFDocument doc = new HWPFDocument(new ByteArrayInputStream(bytes))) { WordExtractor extractor = new WordExtractor(doc); String raw = extractor.getText(); // 老格式按行切块,表格结构交给后续人工或转换工具处理 List<WordBlock> blocks = new ArrayList<>(); int index = 0; for (String line : raw.split("\\r?\\n")) { String text = clean(line); if (!text.isEmpty()) { blocks.add(new WordBlock("PARAGRAPH", index++, text)); } } return new ParsedWord(raw, blocks); } } }WordExtractor拿到的文本里会带\r、\u0007这类控制字符,单元格分隔符在 .doc 里常表现为\u0007,直接入库会让前端显示乱码,所以后面必须过一遍clean。HWPFDocument同样要关,WordExtractor不需要单独关。如果业务对表格要求高,我一般会在上传后加一个“转 docx”的异步步骤,用格式转换工具转完再解析,比在 HWPF 里抠表格省事。
4.2 编码与特殊字符清洗
Java 读 Word 本身拿到的是 Unicode 字符串,真正容易出乱码的是文件名和数据库连接。上传文件名在部分容器里按 ISO-8859-1 解码,中文会变问号;数据库连接没加useUnicode=true&characterEncoding=utf8时,插入中文也可能变问号。文本内容里的\u00A0是不间断空格,trim()去不掉,Word 复制粘贴特别常见,不清洗会在前端显示成奇怪的空格。
public static String clean(String raw) { if (raw == null) { return ""; } return raw .replace('\u00A0', ' ') // 不间断空格 .replace("\u0007", " | ") // .doc 单元格分隔符 .replaceAll("[\\p{Cntrl}&&[^\\r\\n\\t]]", "") // 去掉控制字符 .replaceAll("[ \\t]{2,}", " ") // 连续空格压成一个 .trim(); }\p{Cntrl}是控制字符类,排除\r\n\t是因为段落里需要保留换行和制表。\u0007在 .doc 里很常见,替换成|让表格内容至少可读。这个clean方法要放在解析器里统一调用,不要指望入库层再洗,入库层拿到的应该是干净文本。文件名乱码可以在 Controller 里用new String(original.getBytes("ISO-8859-1"), StandardCharsets.UTF_8)兜底,但更稳的是让前端用 UTF-8 编码上传,后端容器也统一 UTF-8。
4.3 大文件的内存控制与异步任务
XWPFDocument会把整个 docx 解压到内存,几十兆的文档加上图片,堆内存很容易被打满。控制手段有三层:上传层限制max-file-size,解析层限制文本长度,执行层用异步线程池隔离。异步线程池别用默认的SimpleAsyncTaskExecutor,那个每次请求都新建线程,并发一高就失控。
@Configuration @EnableAsync public class AsyncConfig { @Bean("wordExecutor") public Executor wordExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); // 常驻线程,按 CPU 和 IO 情况调 executor.setMaxPoolSize(4); // 峰值线程 executor.setQueueCapacity(50); // 排队上限,满了触发拒绝策略 executor.setThreadNamePrefix("word-ingest-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }核心线程 2、最大 4 是保守值,Word 解析是 CPU 和 IO 混合型,线程开太多反而互相抢内存。CallerRunsPolicy让队列满时由调用线程执行,起到背压作用,但上传接口会变慢,生产环境可以换成自定义拒绝策略,把任务落库稍后重试。异步方法里不要直接传MultipartFile,临时文件可能已经被容器清理,正确做法是先在主线程把文件落盘或读成byte[],再把路径或字节传给异步方法。
5. 避坑与排查:上线后最容易翻车的五个点
Word 解析的坑大多不在代码语法,而在文档结构和运行环境。下面五条是实际项目里出现频率最高的,每条按“现象、原因、解决”写,照着排查能省不少时间。
5.1 现象:解析出来正文全是空白
现象是上传成功、状态也变成成功,但content_text是空字符串,块表里只有零星几条。原因通常有两个:一是文档正文全在表格里,getParagraphs()读不到;二是文档用了文本框,文本在w:txbxContent里,XWPF 的顶层段落 API 也不覆盖。解决方法是先遍历表格,再按需读文本框。表格用getTables()逐行逐单元格读;文本框可以遍历doc.getDocument().getBody()里的 XML 节点,找txbxContent下的w:t文本。更省事的做法是先把文档另存为纯文本对照,确认内容到底在哪个结构里,再针对性补解析逻辑。
5.2 现象:上传几十兆文件后服务 OOM
现象是上传小文件正常,一传大文件堆内存飙升,日志里出现OutOfMemoryError: Java heap space,严重时整个服务重启。原因是XWPFDocument全量加载,文档里的图片、嵌入对象都进内存,再加上MultipartFile.getBytes()又复制一份,内存翻倍。解决分三层:上传层把max-file-size设到业务能接受的上限;解析层不要用getBytes(),改为先落盘再传FileInputStream给XWPFDocument;图片处理改成边读边写,不要把所有byte[]攒在 List 里。堆内存实在紧张时,把解析拆到独立服务,用消息队列削峰。
5.3 现象:中文文件名或正文变问号
现象是上传后文件名显示成????.docx,或者正文里的中文变成问号。原因分两种:文件名乱码是容器按 ISO-8859-1 解码 multipart 头;正文乱码是数据库连接没指定 UTF-8,或者表字符集是latin1。解决方法是数据库连接串加useUnicode=true&characterEncoding=utf8,建表和字段统一utf8mb4;文件名在 Controller 里做一次编码兜底,同时确认前端form-data的 filename 按 UTF-8 发送。正文本身从 POI 出来是 Unicode,只要入库链路是 UTF-8,一般不会坏,坏的大多是连接层。
5.4 现象:插入时报字段超长或数据截断
现象是解析成功,落库时抛Data too long for column 'content'或MysqlDataTruncation。原因是word_block.content用了TEXT,上限约 64KB,一个大表格拼出来就可能超;主表content_text如果误用TEXT也会超。解决方法是将正文和块内容字段改成LONGTEXT,同时在 Java 侧对单块内容做长度保护,超过阈值就截断并记录日志。error_msg也要截断,异常堆栈很容易超过VARCHAR(1000)。建表时别拍脑袋选类型,Word 文档的表格文本长度经常超出预期。
5.5 现象:同一份文件重复上传,库里多出好几条
现象是用户连续点两次上传,或者网络重试,主表出现多条相同文件记录,块表也跟着重复。原因是主表只做了普通索引,没有唯一约束,Service 里也没有先查后插。解决方法是给md5加唯一键,插入时捕获DuplicateKeyException,直接返回已有记录的 id。更稳的做法是先算 md5 查一次,存在就跳过解析;并发场景下唯一键是最后一道防线。注意 md5 要基于文件内容算,不要基于文件名,文件名相同内容不同的情况很常见。
6. 二次开发:把“能读”改成“能查、能扩、能回滚”
这套代码跑通之后,下一步通常是扩格式和加检索。扩格式不要在一个if-else里堆 PDF、Excel、PPT,抽一个解析器接口,按扩展名路由。验证不要只靠手点,准备一组样本文档,覆盖纯段落、多表格、带图片、.doc 老格式、超大文件,每次改解析逻辑跑一遍,对比块数量和关键文本。下面这个接口很轻,但能把扩展成本压得很低。
public interface WordParser { // 支持的扩展名,用于路由 boolean supports(String ext); // 解析字节数组,返回全文和结构化块 ParsedWord parse(byte[] bytes) throws IOException; }路由时注入List<WordParser>,按supports选第一个匹配的,加 PDF 解析器时只需要新增一个实现类,Service 不用改。数据库侧我一般会保留原始文件路径,解析失败时能拿原文件重放,不用让用户重传。status字段配合定时任务扫超时记录,形成闭环。下面这张表是我在二次开发时优先确认的字段,改动前先看它们有没有被下游依赖。
| 字段 | 用途 | 改动注意 |
|---|---|---|
md5 | 去重唯一键 | 改算法要重刷历史数据 |
status | 解析状态机 | 新增状态要同步前端枚举 |
storage_path | 原始文件路径 | 迁移存储时要批量更新 |
content_text | 全文检索 | 改类型要评估索引长度 |
从那以后,我每次接文件解析需求,都先把原始文件落盘、再写解析,解析失败还能拿原文件重放,不至于抓瞎。这份项目里的建表脚本和示例文档可以直接对照跑,先把最小闭环跑通,再按自己的业务加字段和解析器。希望帮到你。
本文还有配套的精品资源,点击获取