尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

SpringBoot电子发票管理系统实战:PDF解析、查重与防重复报销

发布时间:2026/9/26 13:13:04

资讯中心
01
ARTICLE

SpringBoot电子发票管理系统实战:PDF解析、查重与防重复报销

SpringBoot电子发票管理系统实战:PDF解析、查重与防重复报销
简介这是一套基于Java Spring Boot的电子发票管理系统完整项目源码面向学习企业级Java开发的学生、初级开发者及需要课程设计或毕业设计参考的技术人员。项目围绕电子发票的开具、录入、存储备份、查询审核、报表统计与税务合规检查等业务展开后端采用Spring Boot整合Spring MVC、Spring Data JPA、Spring Security等技术前端结合HTML、CSS与JavaScript实现界面交互并涉及MySQL等数据库存储与Restful API数据交换适合作为理解前后端分离与业务系统落地的实践素材。压缩包共963个文件约2.97MB其中html、css、png、js等前端资源占比较大另有39个java源文件、22个json配置、2个db数据库文件及xml、md说明文档结构完整便于按模块查阅。目前已有85人学习下载可帮助读者快速梳理项目分层、接口设计与发票业务逻辑作为二次开发或功能扩展的起点。1. 电子发票管理系统从一张 PDF 到一套能跑起来的 SpringBoot 工程很多做企业信息化的朋友第一次接到「电子发票管理系统」需求时脑子里想的都是「不就是上传个 PDF 存一下吗」真动手才发现坑深得很发票号码要唯一、PDF 要防重复报销、XML 原件和 OFD 版式文件得一起归档、还要能按开票日期和金额区间做统计。这套系统本质上是把发票从「文件」变成「结构化数据 可追溯凭证」的过程核心动作就三件——解析、入库、查重。用 Java SpringBoot 来做是最稳的路线生态成熟、MyBatis 和 POI 都能直接接上前后端分离也好拆。这篇笔记面向的是要真正落地一套能用的发票管理后台的开发者从建表、解析、查重一路讲到部署和踩坑新手能照着敲熟手能直接拿去改参数。2. 先想清楚数据模型发票到底该拆成几张表2.1 为什么不能一张 invoice 表打天下我见过太多人上来就建一张宽表把发票代码、号码、金额、税额、购买方、销售方全塞进去结果第二周需求来了——一张发票可能有多个明细行要按商品明细做统计宽表直接崩。电子发票的结构天然是「主表 明细表 附件表」三层。主表存发票头信息代码、号码、开票日期、价税合计、购销双方明细表存每一行的货物名称、规格、数量、单价、金额、税率附件表存 PDF、OFD、XML 三种原始文件的路径和哈希值。这样拆的好处是查重可以精确到「发票代码 发票号码」这个业务唯一键统计可以下钻到商品维度附件表还能独立做去重和归档。别嫌麻烦这一步想清楚后面省一半返工。2.2 建表 SQL 与字段类型选择金额字段千万别用 float 或 double浮点误差在财务场景是致命的统一用DECIMAL(18,2)。发票代码和号码用VARCHAR而不是数字类型因为前导零会丢。下面是我实际用的建表脚本-- 发票主表 CREATE TABLE t_invoice ( id BIGINT PRIMARY KEY AUTO_INCREMENT, invoice_code VARCHAR(20) NOT NULL COMMENT 发票代码, invoice_no VARCHAR(20) NOT NULL COMMENT 发票号码, invoice_type TINYINT NOT NULL COMMENT 1电普 2电专 3卷票, invoice_date DATE NOT NULL COMMENT 开票日期, buyer_name VARCHAR(200) COMMENT 购买方名称, buyer_tax_no VARCHAR(30) COMMENT 购买方税号, seller_name VARCHAR(200) COMMENT 销售方名称, seller_tax_no VARCHAR(30) COMMENT 销售方税号, amount DECIMAL(18,2) COMMENT 不含税金额, tax_amount DECIMAL(18,2) COMMENT 税额, total_amount DECIMAL(18,2) COMMENT 价税合计, check_code VARCHAR(30) COMMENT 校验码, file_hash CHAR(64) COMMENT PDF文件SHA256, create_by BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code_no (invoice_code, invoice_no), KEY idx_date (invoice_date), KEY idx_hash (file_hash) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 发票明细表 CREATE TABLE t_invoice_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, invoice_id BIGINT NOT NULL, item_name VARCHAR(200), spec VARCHAR(100), unit VARCHAR(20), quantity DECIMAL(18,4), unit_price DECIMAL(18,6), item_amount DECIMAL(18,2), tax_rate DECIMAL(6,4), KEY idx_invoice (invoice_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 附件表 CREATE TABLE t_invoice_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, invoice_id BIGINT NOT NULL, file_type VARCHAR(10) COMMENT PDF/OFD/XML, file_path VARCHAR(500), file_size BIGINT, sha256 CHAR(64), KEY idx_invoice (invoice_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_code_no这个唯一索引是整个查重体系的地基数据库层面直接挡住重复插入比在应用层查一遍再插要可靠得多并发场景下不会出现「查的时候没有、插的时候撞车」。file_hash索引用于识别「同一张 PDF 被改了文件名重新上传」的情况。2.3 发票类型枚举与状态机发票类型别用魔法数字散落在代码里定义枚举。状态机也要提前想好待解析、已入库、已查验、已报销、已作废。很多系统后期要接报销流程状态字段留好不然又要改表。public enum InvoiceType { ELECTRONIC_NORMAL(1, 电子普通发票), ELECTRONIC_SPECIAL(2, 电子专用发票), ROLL(3, 卷式发票); private final int code; private final String desc; InvoiceType(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } }枚举的code和数据库invoice_type对应前端传值、后端转换都走这一套避免各处硬编码 1、2、3。3. 解析 PDF 发票PDFBox 抽取文本的完整链路3.1 选 PDFBox 还是别的库解析电子发票 PDF 主流就两条路一是用 PDFBox 直接抽文本层二是调第三方 OCR。电子发票是系统生成的文本层完整PDFBox 足够速度快、不花钱、可离线。OCR 只在扫描件场景才需要普通电子发票用不上别一上来就上重武器。常见做法是 PDFBox 抽文本 正则匹配字段稳定且好维护。引入依赖dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.29/version /dependency版本别乱升2.0.x 和 3.x 的 API 有差异团队里统一一个版本避免有人本地能跑、服务器报NoSuchMethodError。3.2 抽取文本并定位关键字段电子发票的文本层是按坐标排布的直接getText()出来的顺序可能乱但字段名和值通常挨着。我的做法是先抽全文再用正则按「字段名 冒号 值」的模式抓。public String extractText(MultipartFile file) throws IOException { try (PDDocument doc PDDocument.load(file.getInputStream())) { PDFTextStripper stripper new PDFTextStripper(); // 按位置排序减少乱序 stripper.setSortByPosition(true); return stripper.getText(doc); } } public InvoiceDTO parse(String text) { InvoiceDTO dto new InvoiceDTO(); // 发票代码10-12位数字 dto.setInvoiceCode(match(text, 发票代码[:]?\\s*(\\d{10,12}))); // 发票号码8位数字 dto.setInvoiceNo(match(text, 发票号码[:]?\\s*(\\d{8}))); // 开票日期 dto.setInvoiceDate(match(text, 开票日期[:]?\\s*(\\d{4}年\\d{1,2}月\\d{1,2}日))); // 价税合计注意大小写金额两处 dto.setTotalAmount(new BigDecimal( match(text, 价税合计.*?[¥]\\s*([\\d,]\\.\\d{2})).replace(,, ))); return dto; } private String match(String text, String regex) { Matcher m Pattern.compile(regex).matcher(text); return m.find() ? m.group(1) : null; }setSortByPosition(true)这行很关键不加的话同一行的字段可能被拆到不同段落正则就抓不到了。正则里的[:]?兼容中英文冒号\\s*吃掉空格因为不同开票软件导出的 PDF 空格数量不一样。金额匹配用[¥]兼容两种货币符号抓到后去掉千分位逗号再转BigDecimal。3.3 解析失败怎么办兜底与人工复核正则不是万能的遇到版式特殊的发票会返回 null。我的策略是解析后做一次完整性校验发票代码、号码、金额三个必填项任一为空就把这条记录标记为「待人工复核」原始 PDF 照样存下来不阻断上传流程。千万别在解析失败时直接抛异常让用户重传用户体验极差而且有些发票就是格式特殊人工补录更快。if (dto.getInvoiceCode() null || dto.getInvoiceNo() null || dto.getTotalAmount() null) { dto.setStatus(InvoiceStatus.NEED_REVIEW.getCode()); }4. 查重与防重复报销三层拦截怎么设计4.1 业务唯一键查重第一层是数据库唯一索引前面建表时已经加了uk_code_no。插入时用INSERT ... ON DUPLICATE KEY UPDATE或者捕获DuplicateKeyException把重复的发票友好地提示出来。try { invoiceMapper.insert(dto); } catch (DuplicateKeyException e) { throw new BizException(该发票已存在发票号码 dto.getInvoiceNo()); }4.2 文件哈希查重第二层是文件哈希。有人会把同一张发票改个文件名重新上传业务键能挡住但如果发票代码号码被 P 图改过呢这时候文件哈希就派上用场。上传时先算 SHA256查t_invoice_file.sha256命中就直接拒绝。public String sha256(InputStream in) throws Exception { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { md.update(buf, 0, len); } StringBuilder sb new StringBuilder(); for (byte b : md.digest()) { sb.append(String.format(%02x, b)); } return sb.toString(); }用流式读取而不是一次性readAllBytes大文件不会撑爆内存。4.3 报销状态二次校验第三层是业务状态。一张发票可能已经报销过了再次提交时要拦住。查询时带上状态条件SELECT COUNT(1) FROM t_invoice WHERE invoice_code #{code} AND invoice_no #{no} AND status IN (4, 5) -- 已报销、已作废三层拦截叠加基本能覆盖绝大多数重复报销场景。血泪经验是只做应用层查重不做数据库唯一索引高并发下必然漏别省这个索引。5. 避坑与排查上线后最常翻车的五个点5.1 上传大 PDF 报 413 或内存溢出现象用户上传 20MB 的发票 PDF接口返回 413 或者服务直接 OOM。 原因SpringBoot 默认单文件大小限制 1MB且用MultipartFile.getBytes()会把整个文件读进内存。 解决在application.yml里放开限制并且解析时用getInputStream()流式处理。spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB5.2 中文文件名乱码现象上传后存到磁盘的文件名变成????.pdf。 原因Tomcat 默认用 ISO-8859-1 解析请求头里的文件名。 解决在配置里指定编码或者手动new String(name.getBytes(ISO-8859-1), UTF-8)转一次。更稳的做法是存储时用 UUID 重命名原始文件名单独存字段。5.3 正则匹配金额抓到错误的值现象价税合计抓成了「合计金额」那一行的数。 原因发票上有「金额」「税额」「价税合计」多个含金额的行正则太宽泛。 解决正则里带上「价税合计」这个前缀做锚点并且用非贪婪匹配.*?必要时限定在同一行内匹配。5.4 并发上传同一张发票导致重复入库现象用户手抖点了两次提交数据库里出现两条相同发票。 原因应用层「先查后插」存在竞态窗口。 解决靠数据库唯一索引兜底捕获DuplicateKeyException转成友好提示前端再做按钮防抖。5.5 日期格式因地区设置解析失败现象本地测试正常服务器上SimpleDateFormat解析「2024年1月5日」抛异常。 原因服务器 Locale 不是中文yyyy年M月d日里的「年」「月」「日」被当成普通字符但 Locale 影响数字解析。 解决显式指定Locale.CHINA或者干脆用正则把年月日抽成数字再拼LocalDate。DateTimeFormatter fmt DateTimeFormatter.ofPattern(yyyy年M月d日, Locale.CHINA); LocalDate date LocalDate.parse(2024年1月5日, fmt);6. 部署与进阶Docker 打包和发票查验接口对接6.1 用 Docker 把 SpringBoot 打成镜像本地跑通只是第一步上线要能一键部署。写个 Dockerfile用分层构建减小镜像体积FROM openjdk:17-jre-slim WORKDIR /app COPY target/invoice-system.jar app.jar # 时区必须设否则发票日期会差 8 小时 ENV TZAsia/Shanghai EXPOSE 8080 ENTRYPOINT [java, -Xmx512m, -jar, app.jar]-Xmx512m限制堆内存发票解析是 IO 密集型不是计算密集型堆不用给太大给多了反而拖慢 GC。TZ环境变量一定要设我踩过这个坑容器默认 UTC发票日期全差一天排查了半天。6.2 对接税务查验接口的注意点真实业务里发票入库后往往要调税务平台的查验接口核验真伪。对接时注意三点一是查验有频率限制别批量循环猛调加个队列和限流二是查验结果要落库别每次查询都实时调接口三是网络超时要设短一点查验接口挂了不能拖垮主流程用异步任务处理。Async(checkExecutor) public void asyncCheck(Long invoiceId) { try { CheckResult r taxClient.check(invoiceId); invoiceMapper.updateCheckResult(invoiceId, r.getStatus(), r.getMsg()); } catch (Exception e) { log.warn(查验失败稍后重试 invoiceId{}, invoiceId, e); } }线程池checkExecutor单独配置核心线程数别超过查验接口允许的并发数队列满了走拒绝策略记录日志不要用默认的CallerRunsPolicy阻塞主线程。6.3 一个验证解析准确率的小技巧上线前拿一批真实发票跑一遍解析统计字段抽取成功率。我一般写个临时接口批量上传一个目录的 PDF输出每个文件的解析结果和失败字段人工核对前 50 张。准确率低于 95% 就说明正则要调别急着上线。这个习惯帮我省过好几次线上事故。Test public void batchParse() throws IOException { File dir new File(/data/samples); int total 0, ok 0; for (File f : dir.listFiles()) { total; InvoiceDTO dto parser.parse(extractText(f)); if (dto.getInvoiceCode() ! null dto.getInvoiceNo() ! null) { ok; } else { System.out.println(解析失败: f.getName()); } } System.out.printf(成功率: %.2f%%%n, ok * 100.0 / total); }这套系统我从建表到上线大概花了两周真正卡时间的不是写代码而是各种版式发票的正则适配和查验接口的联调。我的习惯是每接一种新票种先存 20 张样本进测试目录跑一遍批量解析看成功率再决定要不要加特殊规则。别指望一套正则吃遍所有发票留好人工复核入口比追求 100% 自动解析更实际。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。