☰
基于SpringBoot的电子发票管理系统:数据模型、状态机与去重验真实践
2026/10/17 22:34:59 网站建设 项目流程

简介:这是一套基于Java Spring Boot的电子发票管理系统完整源码,面向学习企业级Java开发的学生、初级开发者及需要课程设计或毕业设计参考的技术人员,帮助理解发票录入、自动开具、存储备份、查询审核、报表统计与税务合规检查等业务闭环的实现思路。压缩包共963个文件,约2.97MB,其中html、css、png、js等前端资源占比较大,用于构建管理界面与交互;39个java文件承载Spring Boot后端核心逻辑,配合json、xml、db等配置与数据文件,以及md、txt说明文档,整体结构清晰,便于按模块阅读与二次开发。已有87人学习关注。资源覆盖Spring MVC、Spring Data JPA、Spring Security及MySQL等常见技术栈,读者可从中获取分层架构设计、接口组织方式与前端页面搭建的参考,适合作为学习Spring Boot全栈开发的实践素材。

1. 电子发票管理系统到底在管什么:从一张 PDF 到一套可查可验的账

很多团队第一次做电子发票管理,脑子里想的是「上传 PDF、列表展示、下载」,真上线才发现麻烦全在别处:同一张发票被重复报销、发票号与金额对不上、开票方和受票方信息散落在文件里没法检索、月底对账要人工翻几十个文件夹。基于 Java SpringBoot 的电子发票管理系统,本质是把「发票文件」变成「结构化数据 + 状态流转 + 可审计记录」的一整套服务,而不是一个网盘。

它适合三类人:一是做企业财务中台的后端工程师,需要把发票纳入报销和记账流程;二是做 SaaS 产品的团队,要给多租户提供发票归集能力;三是做毕设或练手的开发者,想找一个业务闭环完整、技术栈主流的项目。核心难点不在 SpringBoot 本身,而在发票字段的抽取、去重规则、状态机设计,以及文件存储与数据库事务的一致性。下面按「先立模型、再跑通链路、最后避坑」的顺序讲清楚。

2. 先定数据模型:发票主表、明细表与文件表怎么切

2.1 为什么不能把发票信息全塞一张表

新手最容易犯的错是建一张invoice表,把发票号、金额、开票日期、购买方、销售方、商品明细全塞进去。结果一张发票有多个商品行时,要么用逗号拼接字符串,要么重复存多行导致主表信息冗余。正确做法是按「主表 + 明细 + 文件」三张表切分,主表存发票级信息,明细存商品行,文件表存 PDF/OFD 的存储路径和哈希值。

这样切的好处是:去重时只查主表的发票代码+发票号码唯一索引;对账时按明细行汇总金额;文件表独立后,换存储方案(本地磁盘换对象存储)不影响业务表。下面给出建表 SQL,字段类型按常见电子发票规范设计,金额统一用DECIMAL(12,2)避免浮点误差。

-- 发票主表:一张发票一条记录 CREATE TABLE invoice_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, invoice_code VARCHAR(20) NOT NULL COMMENT '发票代码', invoice_no VARCHAR(20) NOT NULL COMMENT '发票号码', invoice_date DATE NOT NULL COMMENT '开票日期', total_amount DECIMAL(12,2) NOT NULL COMMENT '价税合计', tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '税额', buyer_name VARCHAR(100) COMMENT '购买方名称', seller_name VARCHAR(100) COMMENT '销售方名称', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待验 1已验 2已报销 3作废', file_id BIGINT COMMENT '关联文件表ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code_no (invoice_code, invoice_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 发票明细表:一张发票多行商品 CREATE TABLE invoice_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, invoice_id BIGINT NOT NULL COMMENT '主表ID', item_name VARCHAR(200) COMMENT '商品名称', quantity DECIMAL(10,2) COMMENT '数量', unit_price DECIMAL(12,2) COMMENT '单价', amount DECIMAL(12,2) COMMENT '金额', KEY idx_invoice_id (invoice_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 文件表:存储路径与哈希,用于去重和防篡改 CREATE TABLE invoice_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_hash CHAR(64) NOT NULL COMMENT 'SHA-256', file_size BIGINT NOT NULL, upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_hash (file_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:uk_code_no唯一索引是防重复报销的第一道闸,插入时如果冲突直接抛异常,业务层捕获后提示「该发票已存在」。file_hash唯一索引防止同一份 PDF 被反复上传占用空间。status字段用 TINYINT 而不是字符串,是为了后续做状态机流转时比较高效,也方便加索引。

参数说明:invoice_code和invoice_no长度按常见电子发票规范留 20 位,实际有些地区代码更短,用 VARCHAR 兼容。金额字段全部DECIMAL(12,2),不要用 FLOAT/DOUBLE,否则对账时会出现0.1+0.2!=0.3的玄学问题。file_hash用 CHAR(64) 存 SHA-256 十六进制串,定长比 VARCHAR 省空间。

2.2 状态机怎么设计才不会被财务追着改

发票状态不是简单的「有效/无效」,而是「待验真 → 已验真 → 已报销 → 已入账 → 作废」这样一条链。如果直接在代码里写if (status == 1) status = 2,后期加一个「部分报销」状态就会改得满地是坑。常见做法是用枚举加允许的迁移表,把合法迁移集中管理。

public enum InvoiceStatus { PENDING(0, "待验真"), VERIFIED(1, "已验真"), REIMBURSED(2, "已报销"), POSTED(3, "已入账"), VOIDED(4, "作废"); private final int code; private final String desc; InvoiceStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 允许的状态迁移:key 当前状态,value 可去的状态集合 private static final Map<InvoiceStatus, Set<InvoiceStatus>> TRANSFER = Map.of( PENDING, Set.of(VERIFIED, VOIDED), VERIFIED, Set.of(REIMBURSED, VOIDED), REIMBURSED,Set.of(POSTED), POSTED, Set.of(), VOIDED, Set.of() ); public static boolean canTransfer(InvoiceStatus from, InvoiceStatus to) { return TRANSFER.getOrDefault(from, Set.of()).contains(to); } }

逻辑说明:TRANSFER用Map.of定义合法迁移,canTransfer在业务层更新状态前调用,不合法就抛业务异常。这样加新状态时只改这一处,不用满项目搜status ==。参数说明:状态码从 0 开始连续,方便前端做下拉;POSTED和VOIDED是终态,集合为空表示不能再流转。

提示:状态迁移一定要在数据库事务里做,并且更新时带上原状态条件,例如UPDATE invoice_main SET status=1 WHERE id=? AND status=0,用受影响行数判断是否并发冲突,避免两个请求同时把待验改成已验。

3. 用 SpringBoot 跑通上传、解析、入库的最小链路

3.1 项目结构与依赖怎么选版本

热词里「springboot版本太高」是真实痛点,很多教程用 3.x 而公司还在 2.7,直接抄会编译不过。我的建议是:如果团队没有强制要求,新项目用 SpringBoot 3.2.x + JDK 17,老项目维护就锁 2.7.x + JDK 8。两者最大的差异是javax.*换成了jakarta.*,以及 Spring Security 配置写法变化。下面以 3.2.x 为例,pom.xml关键依赖如下。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.6</version> </dependency> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>3.0.2</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-core</artifactId> <version>5.8.27</version> </dependency> </dependencies>

逻辑说明:mybatis-plus-spring-boot3-starter是专门给 SpringBoot 3 用的 starter,如果误用旧版mybatis-plus-boot-starter会报jakarta相关类找不到。pdfbox用来抽取 PDF 文本,hutool提供文件哈希和日期工具。参数说明:版本号写死避免依赖漂移,团队内统一。如果必须用 SpringBoot 2.7,把 starter 换成mybatis-plus-boot-starter,JDK 降到 8 或 11。

项目目录按经典分层:controller接请求、service写业务、mapper操作数据库、entity对应表、dto接前端参数、config放配置。不要把所有类堆在启动类同级,后期找起来很痛苦。

3.2 上传接口与文件哈希去重

上传是整条链路的入口,必须同时做三件事:存文件、算哈希、写文件表。如果先写数据库再存文件,存文件失败会产生脏数据;先存文件再写库,写库失败会留下孤儿文件。常见做法是先存临时目录,算完哈希确认不重复后再移到正式目录并写库,用事务包住数据库操作。

@PostMapping("/upload") public Result<Long> upload(@RequestParam("file") MultipartFile file) throws IOException { // 1. 校验类型,只允许 pdf/ofd String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf('.') + 1).toLowerCase(); if (!List.of("pdf", "ofd").contains(ext)) { return Result.fail("仅支持 pdf/ofd"); } // 2. 算 SHA-256,用于去重 byte[] bytes = file.getBytes(); String hash = DigestUtil.sha256Hex(bytes); // 3. 查文件表,已存在直接返回旧 ID InvoiceFile exist = fileMapper.selectByHash(hash); if (exist != null) { return Result.ok(exist.getId()); } // 4. 存盘:按日期分目录,避免单目录文件过多 String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); Path dir = Paths.get(uploadRoot, dateDir); Files.createDirectories(dir); Path target = dir.resolve(hash + "." + ext); Files.write(target, bytes); // 5. 写文件表 InvoiceFile record = new InvoiceFile(); record.setFileName(original); record.setFilePath(target.toString()); record.setFileHash(hash); record.setFileSize((long) bytes.length); fileMapper.insert(record); return Result.ok(record.getId()); }

逻辑说明:先算哈希再查库,重复文件直接复用,省存储也省后续解析。按yyyy/MM/dd分目录是血泪经验,单目录超过几万文件后ls都卡。参数说明:uploadRoot从配置读,不要硬编码;DigestUtil.sha256Hex来自 hutool,输出 64 位十六进制。注意file.getBytes()对大文件会占内存,生产环境建议用流式读取配合DigestInputStream,这里为了可读性用简化写法。

3.3 PDF 文本抽取与字段正则匹配

电子发票 PDF 分两类:一类是文字版,可以直接抽文本;另一类是扫描图片版,需要 OCR。文字版用 PDFBox 的PDFTextStripper就能拿到全部文字,然后用正则匹配发票代码、号码、金额、日期。下面给出抽取和匹配的核心代码。

public InvoiceParseResult parse(Path pdfPath) throws IOException { try (PDDocument doc = Loader.loadPDF(pdfPath.toFile())) { String text = new PDFTextStripper().getText(doc); InvoiceParseResult r = new InvoiceParseResult(); // 发票代码:10 或 12 位数字,前面通常有"发票代码"字样 r.setInvoiceCode(match(text, "发票代码[::]?\\s*(\\d{10,12})")); // 发票号码:8 位数字 r.setInvoiceNo(match(text, "发票号码[::]?\\s*(\\d{8})")); // 开票日期:yyyy年MM月dd日 或 yyyy-MM-dd r.setInvoiceDate(match(text, "开票日期[::]?\\s*(\\d{4}[年-]\\d{1,2}[月-]\\d{1,2})")); // 价税合计:带¥符号的小写金额 r.setTotalAmount(match(text, "价税合计.*?[¥¥]\\s*(\\d+\\.\\d{2})")); return r; } } private String match(String text, String regex) { Matcher m = Pattern.compile(regex).matcher(text); return m.find() ? m.group(1) : null; }

逻辑说明:Loader.loadPDF是 PDFBox 3.x 的新 API,2.x 用PDDocument.load。正则里[::]?兼容中英文冒号,\\s*吃掉空格。参数说明:发票代码长度各地不同,用{10,12}兼容;金额正则用.*?非贪婪匹配到第一个 ¥ 符号,避免跨行匹配到别的金额。匹配不到时返回 null,业务层标记为「待人工核对」,不要直接抛异常导致整批失败。

注意:正则匹配对 PDF 排版敏感,同一张发票换个开票软件文字顺序就变了。生产环境建议把正则做成配置项,按开票方模板分组,匹配失败率高的模板单独调。扫描件必须走 OCR,PDFBox 抽出来是空字符串,别以为是代码 bug。

4. 避坑与排查:上线后最容易被追着问的 5 个问题

4.1 发票号重复但哈希不同,去重失效

现象:同一张发票被上传两次,系统没拦住,产生两条记录。原因:用户第一次传 PDF,第二次传的是截图转的 PDF,文件哈希不同,但发票代码和号码相同。解决:去重不能只靠文件哈希,必须在invoice_main的uk_code_no上做唯一约束,插入时捕获DuplicateKeyException并提示。文件哈希只用于省存储,业务去重靠发票号。

4.2 金额对不上,差几分钱

现象:明细行金额汇总和主表价税合计差 0.01。原因:明细行单价乘数量后四舍五入,多行累加产生舍入误差。解决:明细行金额存的是发票上印刷的金额,不要自己用单价乘数量重算;对账时以主表total_amount为准,明细只做展示。数据库字段用DECIMAL不用DOUBLE,Java 里用BigDecimal且指定RoundingMode.HALF_UP。

4.3 上传大文件时接口超时

现象:超过 10MB 的 PDF 上传报 504。原因:SpringBoot 默认的max-file-size是 1MB,max-request-size是 10MB。解决:在application.yml里调大限制,同时 Nginx 的client_max_body_size也要改,两层都改才生效。

spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB

4.4 状态更新并发覆盖

现象:两个财务同时点「确认报销」,发票被重复入账。原因:先查后改,两个请求都查到状态为「已验真」,然后都改成「已报销」。解决:用带条件的更新语句,UPDATE invoice_main SET status=2 WHERE id=? AND status=1,判断返回的影响行数,为 0 说明状态已被别人改过,提示刷新重试。

4.5 PDFBox 解析报字体缺失

现象:某些 PDF 抽取时报No glyph for或直接抛异常。原因:PDF 内嵌了非标准字体,PDFBox 找不到对应字形。解决:升级 PDFBox 到最新版,或在PDFTextStripper前设置stripper.setSortByPosition(true)改善顺序;仍失败就降级为「人工录入」,不要让整个上传接口挂掉。捕获IOException后记录文件路径,方便后续排查。

5. 进阶技巧:用 MyBatis-Plus 自动生成建表 SQL 与批量验真

5.1 从实体类反推建表语句,减少手写 DDL

热词里「mybatisplus根据java实体类生成创建表的sql语句」是很多人的诉求。MyBatis-Plus 本身不直接生成 DDL,但可以借助它的元数据加一段工具代码,把@TableName、@TableField注解解析成CREATE TABLE。我一般会在测试目录写一个DdlGenerator,只在开发期跑,避免误操作生产库。

public class DdlGenerator { public static String generate(Class<?> entityClass) { TableName table = entityClass.getAnnotation(TableName.class); StringBuilder sql = new StringBuilder("CREATE TABLE ") .append(table.value()).append(" (\n"); for (Field f : entityClass.getDeclaredFields()) { TableId id = f.getAnnotation(TableId.class); TableField tf = f.getAnnotation(TableField.class); if (id == null && tf == null) continue; // 跳过非表字段 String col = id != null ? id.value() : tf.value(); sql.append(" ").append(col).append(" ") .append(typeMap(f.getType())).append(",\n"); } sql.append(" PRIMARY KEY (id)\n) ENGINE=InnoDB;"); return sql.toString(); } private static String typeMap(Class<?> t) { if (t == String.class) return "VARCHAR(255)"; if (t == Long.class) return "BIGINT"; if (t == BigDecimal.class) return "DECIMAL(12,2)"; if (t == LocalDate.class) return "DATE"; return "VARCHAR(255)"; } }

逻辑说明:遍历实体字段,只处理带@TableId或@TableField的,用typeMap做 Java 类型到 SQL 类型的映射。参数说明:typeMap只覆盖常用类型,遇到LocalDateTime要补DATETIME。这个工具生成的是初稿,索引、注释、字符集还得手工补,别指望一键完美。

5.2 批量验真:把 N 次远程调用压成一次

发票验真通常要调外部接口,逐张调用在几百张时慢得让人想砸键盘。常见优化是批量提交,把发票号拼成数组一次请求,返回结果再按号回填。下面用CompletableFuture做并发控制,限制同时最多 5 个请求,避免把对方接口打挂。

public void batchVerify(List<Long> ids) { Semaphore sem = new Semaphore(5); // 并发上限 List<CompletableFuture<Void>> futures = ids.stream().map(id -> CompletableFuture.runAsync(() -> { try { sem.acquire(); InvoiceMain inv = mainMapper.selectById(id); boolean ok = verifyRemote(inv.getInvoiceCode(), inv.getInvoiceNo()); // 只有验真通过才更新状态,带原状态条件防并发 mainMapper.updateStatus(id, 0, ok ? 1 : 4); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { sem.release(); } }, Executors.newFixedThreadPool(5)) ).toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); }

逻辑说明:Semaphore控制并发数,updateStatus带原状态条件,只有当前是「待验真」才更新,避免重复验真覆盖已报销状态。参数说明:并发数 5 是经验值,对方接口有 QPS 限制就调小;线程池要复用,不要每次新建。验真失败的发票状态置为「作废」还是保留「待验真」,取决于业务,我一般保留待验真并记录失败原因,方便人工重试。

5.3 一个验证习惯:先造脏数据再上线

我吃过最大的亏是拿「标准发票」测了一路绿灯,上线后遇到缺字段、金额为负、日期格式乱的发票直接崩。后来养成习惯:上线前用脚本造一批脏数据——发票号带字母、金额为空、日期是「2024.1.1」这种非标准格式、PDF 是加密的——跑一遍全链路,看哪些地方抛异常、哪些地方静默失败。静默失败最可怕,数据悄悄丢了没人知道。所以每个解析字段都要有「解析失败」的兜底状态,宁可标红让人工看,也不要默认填 0。这套系统值不值得做,取决于你愿不愿意在数据质量上花时间,而不是框架选得多新。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询