基于SpringBoot的电子发票管理系统:数据模型、状态机与去重验真实践

发布时间:2026/10/17 22:35:02
基于SpringBoot的电子发票管理系统:数据模型、状态机与去重验真实践
简介这是一套基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 发票明细表一张发票多行商品 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 文件表存储路径与哈希用于去重和防篡改 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_code_no唯一索引是防重复报销的第一道闸插入时如果冲突直接抛异常业务层捕获后提示「该发票已存在」。file_hash唯一索引防止同一份 PDF 被反复上传占用空间。status字段用 TINYINT 而不是字符串是为了后续做状态机流转时比较高效也方便加索引。参数说明invoice_code和invoice_no长度按常见电子发票规范留 20 位实际有些地区代码更短用 VARCHAR 兼容。金额字段全部DECIMAL(12,2)不要用 FLOAT/DOUBLE否则对账时会出现0.10.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 MapInvoiceStatus, SetInvoiceStatus 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 status1 WHERE id? AND status0用受影响行数判断是否并发冲突避免两个请求同时把待验改成已验。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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.6/version /dependency dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version3.0.2/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-core/artifactId version5.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-starterJDK 降到 8 或 11。项目目录按经典分层controller接请求、service写业务、mapper操作数据库、entity对应表、dto接前端参数、config放配置。不要把所有类堆在启动类同级后期找起来很痛苦。3.2 上传接口与文件哈希去重上传是整条链路的入口必须同时做三件事存文件、算哈希、写文件表。如果先写数据库再存文件存文件失败会产生脏数据先存文件再写库写库失败会留下孤儿文件。常见做法是先存临时目录算完哈希确认不重复后再移到正式目录并写库用事务包住数据库操作。PostMapping(/upload) public ResultLong 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 的新 API2.x 用PDDocument.load。正则里[:]?兼容中英文冒号\\s*吃掉空格。参数说明发票代码长度各地不同用{10,12}兼容金额正则用.*?非贪婪匹配到第一个 符号避免跨行匹配到别的金额。匹配不到时返回 null业务层标记为「待人工核对」不要直接抛异常导致整批失败。注意正则匹配对 PDF 排版敏感同一张发票换个开票软件文字顺序就变了。生产环境建议把正则做成配置项按开票方模板分组匹配失败率高的模板单独调。扫描件必须走 OCRPDFBox 抽出来是空字符串别以为是代码 bug。4. 避坑与排查上线后最容易被追着问的 5 个问题4.1 发票号重复但哈希不同去重失效现象同一张发票被上传两次系统没拦住产生两条记录。原因用户第一次传 PDF第二次传的是截图转的 PDF文件哈希不同但发票代码和号码相同。解决去重不能只靠文件哈希必须在invoice_main的uk_code_no上做唯一约束插入时捕获DuplicateKeyException并提示。文件哈希只用于省存储业务去重靠发票号。4.2 金额对不上差几分钱现象明细行金额汇总和主表价税合计差 0.01。原因明细行单价乘数量后四舍五入多行累加产生舍入误差。解决明细行金额存的是发票上印刷的金额不要自己用单价乘数量重算对账时以主表total_amount为准明细只做展示。数据库字段用DECIMAL不用DOUBLEJava 里用BigDecimal且指定RoundingMode.HALF_UP。4.3 上传大文件时接口超时现象超过 10MB 的 PDF 上传报 504。原因SpringBoot 默认的max-file-size是 1MBmax-request-size是 10MB。解决在application.yml里调大限制同时 Nginx 的client_max_body_size也要改两层都改才生效。spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB4.4 状态更新并发覆盖现象两个财务同时点「确认报销」发票被重复入账。原因先查后改两个请求都查到状态为「已验真」然后都改成「已报销」。解决用带条件的更新语句UPDATE invoice_main SET status2 WHERE id? AND status1判断返回的影响行数为 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) ENGINEInnoDB;); 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(ListLong ids) { Semaphore sem new Semaphore(5); // 并发上限 ListCompletableFutureVoid 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。这套系统值不值得做取决于你愿不愿意在数据质量上花时间而不是框架选得多新。希望帮到你。本文还有配套的精品资源点击获取