简介:这是一套基于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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 发票明细表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 附件表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.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 TZ=Asia/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% 自动解析更实际。希望帮到你。
本文还有配套的精品资源,点击获取