ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Servlet+MySQL财务系统:凭证录入到报表导出全链路实现

Servlet+MySQL财务系统:凭证录入到报表导出全链路实现

简介:本资源是一套基于Java EE原生Servlet与MySQL开发的企业级财务管理系统完整实现方案,面向Java Web初学者、课程设计学生及中小型财务系统开发者,解决企业账务处理、资产/成本管理、预算控制与财务分析等核心业务建模与编码实践问题。压缩包共14个文件,含3个MP4项目辅导视频(覆盖项目部署、数据库创建、模块开发全流程)、3张PNG系统界面截图(登录、信息管理等关键页面)、2份DOC/DOCX中期报告与答辩材料、1份PPT毕业答辩演示、1个SQL数据库脚本、1个TXT说明文档及1个源码ZIP包,整体大小117.82MB,结构清晰、开箱即用。已有91人学习下载,资源提供从环境搭建到功能实现的全链路支持,包括可直接运行的源码、可一键导入的MySQL数据表结构、配套论文文档及分模块讲解视频,显著降低Java Web财务系统学习门槛与开发试错成本。

1. 一个不依赖Spring的财务系统:Servlet直连MySQL跑通凭证录入、科目余额、报表导出全流程

上周帮某高校实验室重构一个老财务模块,对方明确说“不要Spring Boot,就想要能看清每一行SQL怎么执行、每个HTTP请求怎么流转的原生实现”。我翻出这个Servlet+MySQL的企业财务管理系统源码包——它没用任何ORM框架,没封装DAO层,连连接池都是手写Druid配置;但偏偏能完整走通从凭证录入→自动更新总账/明细账→生成资产负债表和利润表→Excel导出的全链路。它适合三类人:刚学完JDBC想看真实业务怎么落地的Java新手;需要快速搭个轻量财务后台、又不想被Spring自动配置绕晕的老手;还有做课程设计、毕设要交“可运行+可讲清原理”双材料的学生。整个系统只有5个核心Servlet、3张主表(t_voucher凭证表、t_account科目表、t_balance余额表),没有XML配置、没有注解扫描、没有AOP织入——所有逻辑都在doPost/doGet里裸写,连事务回滚都靠try-catch手动commit/rollback。这不是教学Demo,是真在某小型代理记账公司跑过半年的生产级简化版。


2. 技术栈与项目结构:为什么选Servlet+MySQL而不是Spring Boot?

这个项目的技术选型不是为了怀旧,而是为了解耦和可控性。当你要在凭证保存时同步更新10个科目的期初/本期/期末余额,还要保证借贷平衡校验失败时整条事务回滚,用Spring的@Transactional反而会掩盖底层SQL执行顺序和锁机制。这里用的是最朴素但最透明的组合:Java EE 7标准Servlet API + MySQL 5.7 + Druid 1.2.8连接池 + Apache POI 4.1.2导出Excel。没有Maven多模块,整个工程就是一个标准Web App目录结构,连web.xml都保留了完整的servlet-mapping声明——这意味着你改一行代码,重启Tomcat就能看到效果,不用等Spring容器刷新Bean、不用查@ComponentScan扫到了哪些类。

2.1 目录结构解析:5个Servlet撑起整个财务核心

项目解压后是标准的Java Web目录:

src/ ├── servlet/ │ ├── VoucherAddServlet.java // 凭证新增:含借贷平衡校验、科目合法性检查、余额自动更新 │ ├── AccountListServlet.java // 科目查询:支持按类型(资产/负债/权益/成本/损益)分页 │ ├── BalanceReportServlet.java // 余额表生成:按科目层级递归计算期初/本期/期末余额 │ ├── ProfitLossServlet.java // 利润表:取损益类科目本期发生额汇总,支持按月筛选 │ └── ExportExcelServlet.java // Excel导出:用POI生成带合并单元格的财务报表 ├── dao/ │ └── BaseDao.java // 手写JDBC工具类:含getConnection()、executeUpdate()、executeQuery()封装 ├── entity/ │ ├── Voucher.java // 凭证实体:id, voucherNo, date, remark, List<VoucherDetail> details │ ├── Account.java // 科目实体:code, name, type, level, parentId │ └── Balance.java // 余额实体:accountId, beginBalance, debit, credit, endBalance, period └── util/ └── DBUtil.java // Druid连接池初始化:硬编码driverClassName/url/username/password

提示:BaseDao里没有泛型T,所有SQL都是字符串拼接(比如"INSERT INTO t_voucher (voucher_no, date) VALUES (?, ?)"),参数用PreparedStatement.setObject()逐个设置。这种写法在现代开发中会被喷,但它让你一眼看清SQL执行路径——比如VoucherAddServlet里先insert凭证头,再循环insert凭证行,最后update科目余额表,每步都显式调用conn.commit()或conn.rollback()。

2.2 数据库设计:3张表如何支撑复式记账逻辑

MySQL建表语句直接写在db_init.sql里,关键字段设计直指财务本质:

-- 科目表:支持多级科目(如1001.01 现金-人民币) CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(20) NOT NULL UNIQUE, -- 科目编码,唯一索引 name VARCHAR(50) NOT NULL, -- 科目名称 type TINYINT NOT NULL, -- 类型:1资产 2负债 3权益 4成本 5损益 level TINYINT NOT NULL DEFAULT 1, -- 层级:1一级科目 2二级科目... parent_id BIGINT DEFAULT NULL, -- 上级科目ID,用于树形查询 is_leaf BOOLEAN DEFAULT TRUE -- 是否末级科目(只有末级才能记账) ); -- 凭证表:头+行分离设计,符合会计规范 CREATE TABLE t_voucher ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(20) NOT NULL, -- 凭证字号:2024001 date DATE NOT NULL, -- 记账日期 remark VARCHAR(200), -- 摘要 status TINYINT DEFAULT 0 -- 状态:0未审核 1已审核(影响余额更新) ); CREATE TABLE t_voucher_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_id BIGINT NOT NULL, account_code VARCHAR(20) NOT NULL, -- 关联t_account.code debit DECIMAL(12,2) DEFAULT 0.00, -- 借方金额 credit DECIMAL(12,2) DEFAULT 0.00, -- 贷方金额 FOREIGN KEY (voucher_id) REFERENCES t_voucher(id) ON DELETE CASCADE ); -- 余额表:按科目+期间存储,避免每次查账都sum() CREATE TABLE t_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, period VARCHAR(7) NOT NULL, -- 期间:格式'2024-01',按月统计 begin_balance DECIMAL(12,2) DEFAULT 0.00, debit DECIMAL(12,2) DEFAULT 0.00, -- 本期借方发生额 credit DECIMAL(12,2) DEFAULT 0.00, -- 本期贷方发生额 end_balance DECIMAL(12,2) DEFAULT 0.00, UNIQUE KEY uk_acc_period (account_id, period) );

注意t_balance表的设计:它不存“实时余额”,而是存“某月某科目”的期初、本期、期末余额。这样生成余额表时直接SELECT * FROM t_balance WHERE period='2024-01'即可,不用遍历所有凭证再sum——这是财务系统性能的关键妥协点。而is_leaf字段强制约束“只有末级科目才能出现在凭证行中”,从源头防止用户误选“应收账款”这种一级科目记账。

2.3 运行环境准备:Tomcat 8.5 + MySQL 5.7 配置要点

项目默认使用Tomcat 8.5(兼容Servlet 3.1),MySQL版本要求5.7+(因用到JSON_EXTRACT函数处理科目树形数据)。配置时有三个易错点:

  1. Druid连接池初始化时机:DBUtil.java里用静态块加载驱动并初始化DataSource,但必须确保druid-1.2.8.jar放在WEB-INF/lib/下,且web.xml中无其他监听器抢占ServletContext初始化时机;
  2. MySQL时区问题:若插入凭证日期变成前一天,需在JDBC URL后加?serverTimezone=Asia/Shanghai;
  3. Tomcat权限配置:conf/tomcat-users.xml需添加角色manager-gui和manager-script,否则无法通过网页部署WAR包。

验证是否配好:启动Tomcat后访问http://localhost:8080/financial/login.jsp,输入默认账号admin/123456能进首页,说明Servlet容器、数据库连接、JSP编译全部就绪。


3. 核心功能实现:从凭证录入到报表导出的代码链路

这个系统最值得细读的是VoucherAddServlet——它把会计规则、数据库事务、前端交互全揉在一个类里。下面拆解它如何用200行代码完成“新增凭证→校验平衡→更新余额→返回结果”的闭环。

3.1 凭证新增Servlet:手写事务管理的教科书级案例

// VoucherAddServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Connection conn = null; PreparedStatement psHead = null; PreparedStatement psDetail = null; try { conn = DBUtil.getConnection(); // 从Druid获取连接 conn.setAutoCommit(false); // 关键:关闭自动提交 // 1. 插入凭证头 String sqlHead = "INSERT INTO t_voucher (voucher_no, date, remark) VALUES (?, ?, ?)"; psHead = conn.prepareStatement(sqlHead, Statement.RETURN_GENERATED_KEYS); psHead.setString(1, req.getParameter("voucherNo")); psHead.setDate(2, Date.valueOf(req.getParameter("date"))); psHead.setString(3, req.getParameter("remark")); psHead.executeUpdate(); // 2. 获取生成的voucher_id ResultSet rs = psHead.getGeneratedKeys(); rs.next(); long voucherId = rs.getLong(1); // 3. 插入凭证行(可能多行) String sqlDetail = "INSERT INTO t_voucher_detail (voucher_id, account_code, debit, credit) VALUES (?, ?, ?, ?)"; psDetail = conn.prepareStatement(sqlDetail); String[] codes = req.getParameterValues("accountCode"); String[] debits = req.getParameterValues("debit"); String[] credits = req.getParameterValues("credit"); BigDecimal totalDebit = BigDecimal.ZERO; BigDecimal totalCredit = BigDecimal.ZERO; for (int i = 0; i < codes.length; i++) { if (codes[i] == null || codes[i].trim().isEmpty()) continue; psDetail.setLong(1, voucherId); psDetail.setString(2, codes[i]); BigDecimal d = new BigDecimal(debits[i]); BigDecimal c = new BigDecimal(credits[i]); psDetail.setBigDecimal(3, d); psDetail.setBigDecimal(4, c); psDetail.addBatch(); totalDebit = totalDebit.add(d); totalCredit = totalCredit.add(c); } // 4. 借贷平衡校验(会计铁律) if (!totalDebit.equals(totalCredit)) { throw new RuntimeException("借贷不平衡!借方合计:" + totalDebit + ",贷方合计:" + totalCredit); } // 5. 执行批量插入 psDetail.executeBatch(); // 6. 更新余额表(核心业务逻辑) updateBalanceTable(conn, voucherId); conn.commit(); // 全部成功才提交 req.setAttribute("msg", "凭证保存成功!"); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { } } req.setAttribute("error", "保存失败:" + e.getMessage()); e.printStackTrace(); } finally { // 手动关闭资源(没用try-with-resources,为显式控制) DBUtil.close(psDetail); DBUtil.close(psHead); DBUtil.close(conn); } req.getRequestDispatcher("voucher_add.jsp").forward(req, resp); }

这段代码的价值不在语法,而在它暴露了财务系统的三个硬约束:

  • 事务边界清晰:conn.setAutoCommit(false)到conn.commit()之间包裹所有DB操作,中间任何一步异常都会触发rollback();
  • 业务校验前置:借贷平衡检查放在DB写入之前,避免无效数据入库;
  • 余额更新解耦:updateBalanceTable()是独立方法,后续要改成异步更新或增加审计日志,只改这一处即可。

3.2 余额更新逻辑:如何用一条SQL更新多个科目余额

updateBalanceTable()方法是性能关键。它不遍历凭证行去查科目ID,而是用MySQL的INSERT ... ON DUPLICATE KEY UPDATE语法,一条SQL搞定“不存在则插入,存在则更新”:

private void updateBalanceTable(Connection conn, long voucherId) throws SQLException { // 1. 先查出该凭证所有行涉及的科目编码 String selectSql = "SELECT account_code, debit, credit FROM t_voucher_detail WHERE voucher_id = ?"; PreparedStatement psSelect = conn.prepareStatement(selectSql); psSelect.setLong(1, voucherId); ResultSet rs = psSelect.executeQuery(); // 2. 构建批量UPSERT SQL(MySQL特有语法) String upsertSql = "INSERT INTO t_balance (account_id, period, begin_balance, debit, credit, end_balance) " + "SELECT a.id, ?, 0, ?, ?, ? FROM t_account a " + "WHERE a.code = ? " + "ON DUPLICATE KEY UPDATE " + "debit = debit + VALUES(debit), " + "credit = credit + VALUES(credit), " + "end_balance = begin_balance + debit + credit - VALUES(credit)"; PreparedStatement psUpsert = conn.prepareStatement(upsertSql); String period = getPeriodByVoucherId(voucherId); // 工具方法:从voucher.date提取'2024-01' while (rs.next()) { String code = rs.getString("account_code"); BigDecimal debit = rs.getBigDecimal("debit"); BigDecimal credit = rs.getBigDecimal("credit"); BigDecimal endBalance = calculateEndBalance(code, debit, credit); // 查当前期初余额再算 psUpsert.setString(1, period); psUpsert.setBigDecimal(2, debit); psUpsert.setBigDecimal(3, credit); psUpsert.setBigDecimal(4, endBalance); psUpsert.setString(5, code); psUpsert.addBatch(); } psUpsert.executeBatch(); }

注意:这里用ON DUPLICATE KEY UPDATE而非REPLACE INTO,因为后者会先DELETE再INSERT,会丢失自增ID和关联外键。而VALUES(debit)引用的是INSERT子句中对应列的值,这是MySQL的语法糖,让批量更新更简洁。

3.3 报表生成:BalanceReportServlet如何递归计算科目余额

余额表(试算平衡表)要求显示“一级科目→二级科目→末级科目”的树形结构,并汇总各级余额。BalanceReportServlet没用前端Tree组件,而是用纯SQL递归查询(MySQL 5.7+支持WITH RECURSIVE):

-- 查询某期间所有科目的余额(含父科目汇总) WITH RECURSIVE account_tree AS ( SELECT id, code, name, type, level, parent_id, is_leaf FROM t_account WHERE parent_id IS NULL UNION ALL SELECT a.id, a.code, a.name, a.type, a.level, a.parent_id, a.is_leaf FROM t_account a INNER JOIN account_tree t ON a.parent_id = t.id ) SELECT t.code, t.name, t.level, COALESCE(b.begin_balance, 0) as begin_balance, COALESCE(b.debit, 0) as debit, COALESCE(b.credit, 0) as credit, COALESCE(b.end_balance, 0) as end_balance FROM account_tree t LEFT JOIN t_balance b ON t.id = b.account_id AND b.period = '2024-01' ORDER BY t.code;

Servlet里直接执行这条SQL,结果集按level字段缩进显示(&nbsp;&nbsp;拼接),一级科目不缩进,二级缩进2个空格,三级4个……这样不用JS就能渲染出树形报表。虽然不如Ant Design Tree美观,但胜在零前端依赖,JSP里<c:forEach>遍历即可。


4. 避坑指南:部署与调试中踩过的5个真实坑

这个项目看着简单,但实际部署时90%的问题都出在环境和细节上。以下是我在三台不同机器上反复验证过的典型问题:

4.1 现象:登录成功后跳转到404,地址栏显示http://localhost:8080/financial/index.jsp

原因:项目名不是financial,而是financial-system,但web.xml里<welcome-file-list>写死了index.jsp,而Tomcat部署时WAR包名是financial.war,导致上下文路径不匹配。
解决:修改web.xml的<display-name>为financial,或重命名WAR包为financial.war,确保http://localhost:8080/financial/能访问到根目录。

4.2 现象:凭证录入时提示java.sql.SQLException: Column count doesn't match value count at row 1

原因:t_voucher_detail表结构变更后,VoucherAddServlet里INSERT语句的占位符数量没同步更新。比如新增了currency字段,但SQL还是VALUES (?, ?, ?, ?)四个问号。
解决:检查VoucherAddServlet中所有PreparedStatement的SQL字符串,确保?数量与setXXX()调用次数严格一致。建议用IDEA的SQL语法检查插件高亮提示。

4.3 现象:余额表中“应收账款”科目期末余额为0,但凭证里明明有相关分录

原因:t_account表中该科目is_leaf=FALSE(不是末级科目),而VoucherAddServlet的校验逻辑强制要求凭证行只能填末级科目(is_leaf=TRUE),所以插入时被过滤掉了。
解决:在AccountListServlet的科目列表页面,给非末级科目加灰色禁用样式,并在JS里阻止其被选中;同时在VoucherAddServlet的校验逻辑中增加SELECT COUNT(*) FROM t_account WHERE code=? AND is_leaf=TRUE查询,不满足则抛出明确错误。

4.4 现象:导出Excel时中文乱码,单元格显示“???”

原因:ExportExcelServlet里创建HSSFWorkbook时未设置字体,POI默认用Windows-1252编码,不支持中文。
解决:在创建单元格样式时显式设置字体:

Font font = workbook.createFont(); font.setFontName("微软雅黑"); font.setFontHeightInPoints((short)10); CellStyle style = workbook.createCellStyle(); style.setFont(font); cell.setCellStyle(style);

4.5 现象:Tomcat启动报错java.lang.ClassNotFoundException: com.alibaba.druid.pool.DruidDataSource

原因:druid-1.2.8.jar放在了src/lib/下,而非WEB-INF/lib/。Servlet规范要求第三方JAR必须放在此目录才能被WebApp ClassLoader加载。
解决:将JAR包复制到WEB-INF/lib/,删除src/lib/下的副本;如果用IDEA,右键项目→Add Framework Support→勾选Web Application,它会自动映射lib目录。


5. 二次开发实战:给系统加上凭证审核流与多账套支持

现在系统是“录入即生效”,但真实财务场景需要“制单→审核→记账”三步走。我来演示如何在不改核心架构的前提下,用最小改动加入审核功能,并顺手扩展多账套支持——这两个需求在某代理记账公司的实际改造中只花了半天。

5.1 加入凭证审核状态机:3个字段+2个按钮搞定

现有t_voucher表已有status字段(0未审核/1已审核),但前端没用。改造分三步:

  1. 前端增加审核按钮:在voucher_list.jsp每行凭证后加:
<c:if test="${voucher.status == 0}"> <a href="VoucherAuditServlet?vid=${voucher.id}&action=audit" class="btn btn-sm btn-success">审核</a> </c:if> <c:if test="${voucher.status == 1}"> <span class="text-success">已审核</span> </c:if>
  1. 新增VoucherAuditServlet:只处理审核动作,复用原有连接池:
// VoucherAuditServlet.java String action = req.getParameter("action"); long vid = Long.parseLong(req.getParameter("vid")); if ("audit".equals(action)) { String sql = "UPDATE t_voucher SET status = 1 WHERE id = ? AND status = 0"; PreparedStatement ps = conn.prepareStatement(sql); ps.setLong(1, vid); int rows = ps.executeUpdate(); if (rows == 0) { req.setAttribute("error", "审核失败:凭证已被他人审核或状态异常"); } else { // 审核通过后才更新余额表(原逻辑在VoucherAddServlet里是立即更新) updateBalanceTableAfterAudit(conn, vid); // 新方法:只更新已审核凭证 } }
  1. 改造余额更新时机:把updateBalanceTable()从VoucherAddServlet里删掉,移到VoucherAuditServlet中。这样未审核凭证不会影响余额,符合会计谨慎性原则。

这个改动没碰DAO层、没加新表、没改实体类,只新增一个Servlet和两行JSP,就把状态机跑起来了。关键是把“业务规则”(审核后才记账)和“数据操作”(更新余额)做了物理隔离。

5.2 多账套支持:用租户ID前缀隔离数据

客户提出要服务多个小企业,每个企业一套独立账套。最省事的做法不是分库,而是给所有表加tenant_id字段,用SQL条件隔离:

  1. 修改建表语句(以t_voucher为例):
ALTER TABLE t_voucher ADD COLUMN tenant_id VARCHAR(20) NOT NULL DEFAULT 'default'; ALTER TABLE t_voucher ADD INDEX idx_tenant_vno (tenant_id, voucher_no);
  1. 在BaseDao里注入tenant_id:所有查询SQL末尾加AND tenant_id = ?,参数从HttpServletRequest的session中取:
// BaseDao.java public static String addTenantCondition(String sql, HttpServletRequest req) { String tenantId = (String) req.getSession().getAttribute("tenantId"); return sql + " AND tenant_id = '" + tenantId + "'"; // 生产环境应预编译防SQL注入 }
  1. 登录后设置tenantId:LoginServlet验证账号密码后,根据用户名查出所属租户:
// 示例:admin@companyA → tenantId = "companyA" String username = req.getParameter("username"); String tenantId = username.substring(username.indexOf("@") + 1); req.getSession().setAttribute("tenantId", tenantId);

这样所有查询自动带上租户条件,无需改业务逻辑。后续要支持“同一用户切换账套”,只需在session里换tenantId值即可。

从那以后我每次接到“要加XX功能”的需求,都先翻web.xml看有没有现成的servlet-mapping能复用,再查BaseDao里有没有通用过滤方法。这个项目教会我的不是Servlet怎么写,而是用最少的代码触点,撬动最大的业务扩展性——毕竟财务系统最怕改出借贷不平衡的bug,稳比快重要十倍。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进