简介:这是一套基于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等技术的实际运用范例,适合作为二次开发或功能扩展的起点。
1. 电子发票管理系统:从一张 PDF 到一套能跑起来的 SpringBoot 工程
很多做企业信息化的朋友第一次接触电子发票,都是被一张 PDF 逼到墙角的。业务方丢过来一句「发票要能在线开、能查、能下载、能对账」,你打开一看,PDF 里嵌着 XML,XML 里又有签名值,签名值还得跟税务端的证书对得上。这时候如果还想着用 Excel 加邮件糊弄,基本就是给自己挖坑。基于 Java SpringBoot 的电子发票管理系统,本质上是把「开票、存票、查票、验票、对账」这几件事做成一套可维护的服务,而不是一个临时脚本。它适合两类人:一类是中小团队里被指派做发票模块的后端,另一类是正在做毕设、需要一套完整业务闭环练手的开发者。下面我按自己落地时的顺序,把选型、表结构、核心接口和踩过的坑一次讲清。
2. 为什么用 SpringBoot 而不是裸 Servlet:选型与工程骨架
2.1 电子发票业务对框架的真实诉求
电子发票系统看起来是个 CRUD,但它有几个特殊点:第一,开票请求必须幂等,同一张发票不能因为网络重试开出两次;第二,发票文件(PDF/OFD)和结构化数据(XML/JSON)要同时落库,文件体积不大但数量多;第三,查询维度杂,按购方、按销方、按时间、按状态、按金额区间都要能查。这三点决定了框架必须能方便地做事务、能方便地接对象存储、能方便地写复杂查询。
裸 Servlet 加 JDBC 当然能做,但你要自己管连接池、自己写事务模板、自己处理 JSON 序列化,代码量会膨胀得很快。SpringBoot 的价值不在于「新」,而在于它把这些基础设施都收敛成了 starter,你只要关注发票本身的逻辑。常见做法是用 spring-boot-starter-web 提供 REST 接口,用 spring-boot-starter-jdbc 或 MyBatis 做数据访问,用 spring-boot-starter-validation 做参数校验。版本上不必追最新,选一个团队熟悉且社区资料多的稳定版即可,盲目上高版本反而容易在依赖冲突上翻车。
2.2 最小可运行的工程结构
我一般会把工程拆成四层:controller 只做参数接收和返回封装,service 写业务规则,mapper 或 repository 做数据访问,domain 放实体和 DTO。目录结构大致如下,这是 SpringBoot Web 项目最常见的组织方式,新手照着建不会乱:
invoice-system ├── src/main/java/com/example/invoice │ ├── InvoiceApplication.java │ ├── controller │ │ └── InvoiceController.java │ ├── service │ │ ├── InvoiceService.java │ │ └── impl/InvoiceServiceImpl.java │ ├── mapper │ │ └── InvoiceMapper.java │ ├── domain │ │ ├── entity/Invoice.java │ │ └── dto/InvoiceCreateRequest.java │ └── config │ └── MybatisConfig.java ├── src/main/resources │ ├── application.yml │ └── mapper/InvoiceMapper.xml └── pom.xml启动类保持最简,只加一个注解即可:
package com.example.invoice; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.invoice.mapper") // 扫描 Mapper 接口,避免每个接口都加 @Mapper public class InvoiceApplication { public static void main(String[] args) { SpringApplication.run(InvoiceApplication.class, args); } }这段代码里唯一需要解释的是@MapperScan。如果不加它,你就得在每个 Mapper 接口上写@Mapper,接口一多容易漏。参数上它接收的是包路径,写错包名启动时会报找不到 Bean,这是新手最常见的启动失败原因之一。
2.3 配置文件里必须提前定好的几项
application.yml不要等到报错才补,下面这几项建议一开始就写清楚:
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/invoice_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: invoice_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB # 单张发票 PDF 一般不超过 2MB,留余量 max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 数据库下划线字段自动映射到 Java 驼峰属性serverTimezone必须显式指定,否则 MySQL 8 以上版本在写入时间字段时可能报时区错误。map-underscore-to-camel-case打开后,invoice_code会自动映射到invoiceCode,省掉大量手动 resultMap。文件大小限制按实际发票 PDF 调整,设太大反而给上传接口留下被撑爆的风险。
3. 发票数据模型与表结构:别等对账时才发现字段不够
3.1 一张电子发票到底要存哪些字段
很多人第一版只存了发票号、金额、开票时间,结果业务方要按「购方税号」筛选时傻眼。电子发票的核心字段我建议至少覆盖这几类:标识类(发票代码、发票号码、机器编号)、主体类(销方名称、销方税号、购方名称、购方税号)、金额类(不含税金额、税额、价税合计)、时间类(开票日期)、状态类(开票状态、查验状态)、文件类(PDF 路径、OFD 路径、XML 内容)。这些字段不是拍脑袋来的,是税务端返回报文里本来就有的,你少存一个,后面补数据就要重新调接口。
3.2 建表 SQL 与索引设计
下面这张表是我实际用过的精简版,字段类型按 MySQL 写:
CREATE TABLE `t_invoice` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `invoice_code` VARCHAR(20) NOT NULL COMMENT '发票代码', `invoice_no` VARCHAR(20) NOT NULL COMMENT '发票号码', `seller_name` VARCHAR(100) DEFAULT NULL COMMENT '销方名称', `seller_tax_no` VARCHAR(30) DEFAULT NULL COMMENT '销方税号', `buyer_name` VARCHAR(100) DEFAULT NULL COMMENT '购方名称', `buyer_tax_no` VARCHAR(30) DEFAULT NULL COMMENT '购方税号', `amount_without_tax` DECIMAL(12,2) DEFAULT NULL COMMENT '不含税金额', `tax_amount` DECIMAL(12,2) DEFAULT NULL COMMENT '税额', `total_amount` DECIMAL(12,2) DEFAULT NULL COMMENT '价税合计', `invoice_date` DATE DEFAULT NULL COMMENT '开票日期', `status` TINYINT DEFAULT 0 COMMENT '0待开票 1已开票 2已作废', `pdf_url` VARCHAR(255) DEFAULT NULL COMMENT 'PDF 存储路径', `xml_content` TEXT COMMENT '发票 XML 原文', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code_no` (`invoice_code`,`invoice_no`), KEY `idx_buyer_tax` (`buyer_tax_no`), KEY `idx_invoice_date` (`invoice_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电子发票主表';这里最关键的是uk_code_no这个唯一索引。发票代码加发票号码在全网范围内唯一,把它做成唯一键,就能在数据库层面挡住重复插入,比在代码里查一次再插一次可靠得多。idx_buyer_tax和idx_invoice_date是为查询准备的,购方税号查询和对账按日期范围查询是最高频的两个场景。金额字段一律用DECIMAL,不要用FLOAT,否则对账时会出现几分钱的误差,这种玄学问题查起来非常痛苦。
3.3 实体类与字段映射
实体类跟表字段一一对应,用 Lombok 可以省掉 getter/setter:
package com.example.invoice.domain.entity; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @Data public class Invoice { private Long id; private String invoiceCode; private String invoiceNo; private String sellerName; private String sellerTaxNo; private String buyerName; private String buyerTaxNo; private BigDecimal amountWithoutTax; private BigDecimal taxAmount; private BigDecimal totalAmount; private LocalDate invoiceDate; private Integer status; private String pdfUrl; private String xmlContent; private LocalDateTime createdAt; private LocalDateTime updatedAt; }金额用BigDecimal而不是Double,这是财务系统的铁律。日期用LocalDate和LocalDateTime,不要用java.util.Date,后者在时区处理上容易出问题。字段名和数据库列名的对应关系靠前面配置的驼峰映射自动完成,不需要额外注解。
4. 开票与查验接口:幂等、事务和文件落盘
4.1 开票接口的幂等设计
开票是整套系统里最不能出错的一步。我的做法是:请求进来先按「发票代码+发票号码」查一次,如果已存在且状态是已开票,直接返回已有结果,不再调用外部开票服务。这样即使前端重复提交,也不会开出两张票。
@Service public class InvoiceServiceImpl implements InvoiceService { @Autowired private InvoiceMapper invoiceMapper; @Override @Transactional(rollbackFor = Exception.class) public Invoice createInvoice(InvoiceCreateRequest req) { // 1. 幂等检查:同一张发票只开一次 Invoice exist = invoiceMapper.selectByCodeAndNo(req.getInvoiceCode(), req.getInvoiceNo()); if (exist != null && exist.getStatus() == 1) { return exist; } // 2. 组装实体 Invoice invoice = new Invoice(); invoice.setInvoiceCode(req.getInvoiceCode()); invoice.setInvoiceNo(req.getInvoiceNo()); invoice.setBuyerName(req.getBuyerName()); invoice.setBuyerTaxNo(req.getBuyerTaxNo()); invoice.setTotalAmount(req.getTotalAmount()); invoice.setStatus(1); invoice.setInvoiceDate(LocalDate.now()); // 3. 落库,唯一索引兜底 invoiceMapper.insert(invoice); return invoice; } }@Transactional的rollbackFor要显式写成Exception.class,因为 Spring 默认只对运行时异常回滚,受检异常不会触发回滚,这个坑很多人踩过。幂等检查放在事务内,配合数据库唯一索引,形成双重保险。如果并发极高,唯一索引冲突会抛异常,捕获后转成「已存在」的正常返回即可。
4.2 发票文件的上传与存储
PDF 和 OFD 文件不建议直接存进数据库的 BLOB 字段,一是备份恢复慢,二是查询时容易把内存撑爆。常见做法是存到本地磁盘或对象存储,数据库只存路径。上传接口大致这样写:
@PostMapping("/upload") public String upload(@RequestParam("file") MultipartFile file, @RequestParam("invoiceNo") String invoiceNo) throws IOException { // 按发票号码分目录,避免单目录文件过多 String dir = "/data/invoice/" + invoiceNo.substring(0, 2); File folder = new File(dir); if (!folder.exists()) { folder.mkdirs(); } String path = dir + "/" + invoiceNo + ".pdf"; file.transferTo(new File(path)); // 更新数据库中的 pdf_url invoiceMapper.updatePdfUrl(invoiceNo, path); return path; }按发票号码前两位分目录是个实用技巧,单目录文件超过几万个后,文件系统查找会明显变慢。transferTo直接写磁盘,比先读成字节数组再写要省内存。路径存绝对路径还是相对路径看部署方式,如果后面要迁到对象存储,建议存相对 key,迁移时只改前缀。
4.3 查验状态的更新
发票查验是异步的,通常调用外部接口后拿到结果再回写。这里要注意的是更新时只改状态字段,不要整行覆盖,否则会把并发写入的其他字段冲掉:
<update id="updateStatus"> UPDATE t_invoice SET status = #{status}, updated_at = NOW() WHERE invoice_code = #{invoiceCode} AND invoice_no = #{invoiceNo} </update>这条 SQL 只更新状态和更新时间,条件用发票代码加号码,走唯一索引,速度很快。如果查验失败要记录失败原因,建议单独加一个check_msg字段,不要塞进xml_content里,否则后面解析会乱。
5. 避坑与排查:那些让我加班到凌晨的细节
5.1 时间字段差 8 小时
现象:开票日期存进去是 2024-01-01,查出来变成 2023-12-31 16:00。原因是 JDBC 连接串没指定时区,MySQL 驱动按 UTC 解析。解决办法是在url里加serverTimezone=Asia/Shanghai,同时确认数据库服务器的时区设置。这个坑在跨时区部署时尤其明显,本地测试往往发现不了。
5.2 唯一索引冲突导致接口 500
现象:并发提交同一张发票时,接口返回 500,日志里是DuplicateKeyException。原因是幂等检查在并发下失效,两个线程同时查到不存在,同时插入。解决办法是捕获DuplicateKeyException,转成「发票已存在」的正常业务返回,而不是让它冒泡成系统错误。这也是为什么唯一索引必须建,它是最后一道防线。
5.3 金额用 Double 导致对账差几分钱
现象:单张发票金额看起来对,但汇总几百张后总额差 0.03 元。原因是Double是浮点数,累加时有精度损失。解决办法是所有金额字段用BigDecimal,数据库用DECIMAL,Java 里做加减乘除用BigDecimal的方法,不要用+-*/。这个坑在财务系统里是致命的,对账对不上时排查成本极高。
5.4 Mapper XML 没被扫描到
现象:启动时报Invalid bound statement (not found)。原因是mapper-locations配置的路径和实际文件位置不一致,或者 XML 文件名和接口名对不上。解决办法是确认application.yml里写的是classpath:mapper/*.xml,并且 XML 的namespace指向正确的 Mapper 接口全限定名。打包时还要确认 XML 被包含进 jar,Maven 默认只打包src/main/resources下的文件,如果 XML 放在 java 目录下需要额外配置。
5.5 大文件上传内存溢出
现象:上传 10MB 以上 PDF 时服务重启。原因是 SpringBoot 默认把 multipart 文件缓存在内存,超过阈值才写磁盘。解决办法是在配置里调大max-file-size和max-request-size,同时设置file-size-threshold让大文件直接落盘。如果发票文件普遍较大,建议前端分片上传,后端合并,避免单次请求占用过多内存。
6. 进阶技巧:用状态机管住发票生命周期
发票的状态流转比想象中复杂:待开票、已开票、已查验、已作废、已红冲,每个状态能做的操作不同。如果到处写if (status == 1),代码很快会变成一团乱麻。我的习惯是引入一个轻量状态机,把「当前状态 + 事件 = 新状态」定义成一张表,业务代码只负责触发事件。
public enum InvoiceStatus { PENDING(0, "待开票"), ISSUED(1, "已开票"), CHECKED(2, "已查验"), CANCELLED(3, "已作废"); private final int code; private final String desc; InvoiceStatus(int code, String desc) { this.code = code; this.desc = desc; } public static InvoiceStatus of(int code) { for (InvoiceStatus s : values()) { if (s.code == code) return s; } throw new IllegalArgumentException("未知状态: " + code); } public int getCode() { return code; } public String getDesc() { return desc; } }有了枚举,状态判断就从魔法数字变成了可读的常量。再进一步,可以定义一个canTransferTo方法,规定哪些流转合法:
public boolean canTransferTo(InvoiceStatus target) { switch (this) { case PENDING: return target == ISSUED || target == CANCELLED; case ISSUED: return target == CHECKED || target == CANCELLED; case CHECKED: return target == CANCELLED; default: return false; } }这样在 service 里更新状态前先调一次canTransferTo,非法流转直接拒绝,比事后查日志找原因要省事得多。验证方法也很简单:写一组单元测试,把每个状态到每个目标的组合都跑一遍,断言只有合法流转返回 true。这个习惯帮我挡掉过好几次「已作废的发票又被查验」这类脏数据。
最后说个我自己的教训:早期做发票系统时,我图省事把状态判断散落在各个 service 方法里,结果上线三个月后加了一个「红冲」状态,改了七八个文件还是漏了一处,导致红冲后的发票还能被再次红冲。从那以后,凡是状态超过三个的业务,我一律先抽状态机再写逻辑。希望帮到你。
本文还有配套的精品资源,点击获取