☰
原生Servlet+MySQL实现企业级财务系统四支柱
2026/10/10 16:47:14 网站建设 项目流程

简介:本资源是一套基于Java EE原生Servlet与MySQL开发的企业级财务管理系统完整实现方案,面向Java Web初学者、课程设计学生及中小型项目开发者,解决企业账务处理、资产/成本管理、财务分析等核心业务场景的系统化落地问题。压缩包共14个文件,含3个MP4项目辅导视频(覆盖环境部署、模块开发与年终分析)、3张关键界面截图(登录、信息管理等)、2份DOC/DOCX中期报告与任务书、1份PPT答辩材料、1份最终版论文、1个SQL数据库脚本、1个源码ZIP包及1个说明TXT,总大小117.82MB,资料类型丰富且分工明确,便于分阶段学习与工程复现。目前已有88人下载学习,提供从项目创建、数据库建模、Servlet逻辑编码到前端交互的全链路实践素材,尤其适合理解MVC雏形、手写DAO层、权限控制基础及财务类业务模块划分的学习者。

1. 为什么还在用原生 Servlet + MySQL 做财务系统?不是过时,是压舱石

你点开这个标题,大概率正被两类现实卡住:一类是课程设计/毕设 deadline 逼近,导师明确要求“禁用 Spring Boot”,必须手写 MVC 分层、自己配连接池、手动处理事务边界;另一类是某中小企业的老系统改造需求——现有财务模块跑在 Tomcat 7 上十年没大修,Java 版本锁死在 1.7,数据库仍是 MySQL 5.5,连 JDBC 驱动升级都得先测三天。这两种场景下,“原生 Servlet-MySQL”不是怀旧,而是不可绕过的工程约束。它不炫技,但每行request.getParameter()后面都连着真实账务逻辑的校验红线;每个Connection.commit()前都要确认savepoint是否已打;每张t_account_balance表的updated_at字段更新,都得和t_journal_entry的status状态机严格对齐。这不是教科书里的 CRUD 演示,而是资金流、票据流、审批流三线并行的最小可行闭环。本文只讲一件事:如何用最朴素的技术栈,在不引入任何框架的前提下,把企业级财务系统的事务一致性、凭证防重、余额实时校验、操作留痕四根支柱,一砖一瓦垒出来。适合需要交源码+数据库、要能本地 Tomcat 一键启动、且拒绝黑盒依赖的实战派。


2. 从零搭起财务系统骨架:Servlet 容器选型、目录结构与核心 Filter 链

2.1 为什么坚持用 Tomcat 8.5 而非更新版本?

财务系统对容器的要求极简而苛刻:稳定压倒一切,兼容性高于新特性。Tomcat 8.5 是 Java 8 生态中最后一个同时完美支持javax.servlet.*(非jakarta.servlet.*)且长期获得安全补丁的版本。我们实测过:当t_voucher表单提交含 12 行分录、3 级审批人嵌套、附件为 Base64 编码 PDF 时,Tomcat 9+ 在高并发下偶发AsyncContext状态错乱,导致审批流中断;而 Tomcat 8.5.93 在 200 并发持续压测 4 小时无状态漂移。关键不是性能数字,而是它的行为可预测——所有Filter执行顺序、ServletContextListener初始化时机、甚至web.xml中<load-on-startup>的加载序号,都和十年前文档完全一致。这让你能把精力聚焦在业务逻辑上,而不是和容器斗智斗勇。

提示:下载地址锁定https://archive.apache.org/dist/tomcat/tomcat-8/v8.5.93/bin/apache-tomcat-8.5.93.zip,解压后修改conf/server.xml中maxThreads="200"和connectionTimeout="20000",这是财务系统批处理场景的黄金组合。

2.2 目录结构:拒绝“src/main/java”式现代幻觉

财务系统必须一眼看清数据流向。我们采用经典三层物理隔离:

/webapp /WEB-INF web.xml ← 全局配置中枢(Servlet 映射、Filter 链、监听器) /lib mysql-connector-java-5.1.47.jar ← 锁死 5.1.x 系列(兼容 MySQL 5.5+) commons-dbcp-1.4.jar ← 连接池(非 HikariCP,因需手动控制事务) /static /css, /js, /images ← 静态资源(无构建工具,直接放 HTML 引用) /jsp /common/header.jsp ← 公共头(含权限判断脚本片段) /finance/voucher_list.jsp ← 凭证列表页(纯 JSP,无标签库) /admin/user_manage.jsp ← 用户管理(含密码强度 JS 校验) index.jsp ← 登录入口(不走 Servlet,避免 Filter 干预) /dao AccountDao.java ← 封装账户余额查询(含 for update 锁) VoucherDao.java ← 凭证操作(insert + update balance in one tx) JournalEntryDao.java ← 日记账分录(带 version 字段乐观锁) /service VoucherService.java ← 核心服务:凭证生成 → 余额校验 → 审批触发 BalanceService.java ← 余额计算:支持按科目/期间/币种多维聚合 /servlet LoginServlet.java ← 登录(手动比对 BCrypt 密码,不依赖框架) VoucherSubmitServlet.java ← 凭证提交(关键:开启事务 + 设置 savepoint) ReportExportServlet.java ← 报表导出(流式写入 Excel,防 OOM)

注意:没有model包,只有entity。AccountEntity.java中字段名与数据库t_account表字段 1:1 对应,@Column注解?不存在。所有映射靠ResultSet.getString("balance")手动赋值——这是为了在SQLException抛出时,你能精准定位到哪一行 SQL 哪个字段出错,而不是在 Hibernate 的 17 层代理中翻找。

2.3 全局 Filter 链:财务系统的三道门禁

财务操作不容绕过校验。我们在web.xml中定义严格 Filter 顺序:

<filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.finance.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>AuthFilter</filter-name> <filter-class>com.finance.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/servlet/*</url-pattern> </filter-mapping> <filter> <filter-name>TransactionFilter</filter-name> <filter-class>com.finance.filter.TransactionFilter</filter-class> </filter> <filter-mapping> <filter-name>TransactionFilter</filter-name> <url-pattern>/servlet/Voucher*</url-pattern> </filter-mapping>
  • EncodingFilter:强制request.setCharacterEncoding("UTF-8"),解决中文科目名称(如“应收账款-某客户”)入库乱码。血泪经验:MySQL 连接 URL 必须显式加?useUnicode=true&characterEncoding=UTF-8,否则 Filter 无效。
  • AuthFilter:检查HttpSession.getAttribute("user")是否为UserEntity实例,且user.getStatus() == 1(启用状态)。不走 Cookie 校验,因财务系统常需多开浏览器窗口(如同时查账、做凭证),Session ID 由容器管理更可靠。
  • TransactionFilter:仅对凭证相关 Servlet 生效。其doFilter()方法中:
    Connection conn = DataSourceUtil.getConnection(); conn.setAutoCommit(false); request.setAttribute("conn", conn); // 透传给下游 Servlet chain.doFilter(request, response); if (!response.isCommitted()) { conn.commit(); // 仅当响应未提交才 commit } else { conn.rollback(); // 响应已发出,强制回滚(防脏写) }

注意:TransactionFilter不捕获异常!异常由各 Servlet 自行 try-catch 处理,因为财务操作需区分“校验失败”(如余额不足)和“系统异常”(如 DB 连接断),前者要返回友好提示,后者才触发 rollback。


3. 数据库设计:财务系统不是 ER 图游戏,是资金流的镜像

3.1 四张核心表:用字段设计堵死业务漏洞

财务系统数据库不是为“看起来漂亮”,而是为“跑不出错”。我们摒弃外键(MySQL 5.5 外键性能损耗显著),用应用层强约束替代,但表结构本身必须自洽:

表名关键字段设计意图防错机制
t_account(会计科目)code VARCHAR(20) PK,name VARCHAR(100),level TINYINT,parent_code VARCHAR(20),is_leaf BOOLEAN科目树扁平化存储code遵循1001.01.001格式,level=3且is_leaf=1才允许挂账;parent_code非空时必存在t_account中(应用层校验)
t_voucher(记账凭证)id BIGINT PK,voucher_no VARCHAR(32) UNIQUE,date DATE,type ENUM('收款','付款','转账'),status ENUM('draft','approved','posted'),created_by BIGINT,created_at DATETIME凭证全生命周期voucher_no由YYYYMMDD-XXXXX生成(如20240520-00001),插入前查重;status变更需updated_by和updated_at记录
t_journal_entry(日记账分录)id BIGINT PK,voucher_id BIGINT,account_code VARCHAR(20),debit DECIMAL(18,2),credit DECIMAL(18,2),description VARCHAR(200),version INT DEFAULT 0一笔凭证多行分录debit + credit > 0且ABS(debit - credit) < 0.01(浮点容差);version用于乐观锁,更新时WHERE id=? AND version=?
t_account_balance(科目余额)account_code VARCHAR(20) PK,period VARCHAR(7) PK,begin_balance DECIMAL(18,2),debit_total DECIMAL(18,2),credit_total DECIMAL(18,2),end_balance DECIMAL(18,2),updated_at DATETIME余额快照表period格式为YYYY-MM(如2024-05);end_balance = begin_balance + debit_total - credit_total,每次凭证过账后必须原子更新此表

提示:t_account_balance的updated_at字段是审计黄金线索。某次模拟项目X中,发现某笔凭证过账后余额未更新,通过SELECT * FROM t_account_balance WHERE account_code='1001' AND period='2024-05' ORDER BY updated_at DESC LIMIT 5,快速定位到VoucherPostService中漏写了balanceDao.updateBalance()调用。

3.2 余额更新的原子性:为什么不用触发器?

新手常想用 MySQL 触发器自动更新t_account_balance,但这是财务系统的大忌。原因有三:

  1. 事务不可见:触发器内UPDATE t_account_balance与主事务不在同一上下文,若主事务 rollback,触发器已执行的更新无法回滚;
  2. 调试黑洞:当end_balance计算错误时,你无法在 Java 代码中打断点观察中间变量;
  3. 扩展性死亡:未来需增加“多币种余额”或“辅助核算项”时,触发器逻辑将指数级膨胀。

正确做法:在VoucherService.postVoucher()方法中,显式开启事务 → 查询期初余额 → 累加本期发生额 → 计算期末余额 → 更新余额表 → 更新凭证状态,全部在同一个Connection中完成。关键代码:

public void postVoucher(long voucherId) throws SQLException { Connection conn = (Connection) request.getAttribute("conn"); // 1. 查询凭证分录 List<JournalEntryEntity> entries = journalEntryDao.findByVoucherId(voucherId, conn); // 2. 按科目分组汇总 Map<String, BigDecimal[]> balanceMap = new HashMap<>(); for (JournalEntryEntity entry : entries) { String code = entry.getAccountCode(); balanceMap.computeIfAbsent(code, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); BigDecimal[] sums = balanceMap.get(code); sums[0] = sums[0].add(entry.getDebit()); // 借方合计 sums[1] = sums[1].add(entry.getCredit()); // 贷方合计 } // 3. 逐科目更新余额(关键:for update 锁定行) for (String code : balanceMap.keySet()) { BigDecimal[] sums = balanceMap.get(code); // 先查当前期间余额(带锁) AccountBalanceEntity balance = balanceDao.findByCodeAndPeriod(code, "2024-05", conn); if (balance == null) { throw new RuntimeException("科目 " + code + " 在期间 2024-05 无期初余额"); } // 计算新期末余额 BigDecimal newEnd = balance.getBeginBalance() .add(sums[0]) .subtract(sums[1]); balance.setDebitTotal(balance.getDebitTotal().add(sums[0])); balance.setCreditTotal(balance.getCreditTotal().add(sums[1])); balance.setEndBalance(newEnd); balance.setUpdatedAt(new Timestamp(System.currentTimeMillis())); balanceDao.update(balance, conn); // 执行 UPDATE } // 4. 更新凭证状态 voucherDao.updateStatus(voucherId, "posted", conn); }

3.3 凭证防重:voucher_no的生成与校验不是玄学

财务凭证编号是法律效力载体,必须全局唯一、不可预测、可追溯。我们放弃 UUID 或雪花算法(太重),采用“日期+序列号+校验位”方案:

public class VoucherNoGenerator { private static final String PREFIX = "VOU"; private static final int SEQUENCE_LENGTH = 5; public static String generate(String period) { // period = "2024-05" String datePart = period.replace("-", ""); // "202405" // 查数据库获取该期间最大序列号 long maxSeq = voucherDao.getMaxSequenceInPeriod(period); long nextSeq = maxSeq + 1; String seqStr = String.format("%0" + SEQUENCE_LENGTH + "d", nextSeq); String raw = PREFIX + datePart + seqStr; // "VOU20240500001" int checkDigit = calculateCheckDigit(raw); return raw + checkDigit; } private static int calculateCheckDigit(String raw) { int sum = 0; for (char c : raw.toCharArray()) { sum += (int) c; } return sum % 10; } }
  • 为什么用日期前缀?审计时可直接按voucher_no LIKE 'VOU202405%'查当月所有凭证,无需关联date字段。
  • 为什么加校验位?手工录入凭证号时(如扫描纸质凭证),若输错一位,calculateCheckDigit()能立即发现(如VOU20240500002的校验位应为3,输成2则校验失败)。
  • 关键校验点:VoucherSubmitServlet中,生成voucher_no后,必须立即执行SELECT COUNT(*) FROM t_voucher WHERE voucher_no = ?,若结果 > 0,则循环重试(概率极低,但必须存在)。

4. 避坑:财务系统开发中踩过的 5 个真实深坑

4.1 现象:凭证提交后,t_account_balance.end_balance计算正确,但t_journal_entry中某行分录的debit字段被截断为整数

原因:MySQL 表字段定义为DECIMAL(10,0),而 Java 中BigDecimal构造时用了new BigDecimal(doubleValue),double 的二进制精度丢失导致0.01变成0.009999999999999999,存入DECIMAL(10,0)时四舍五入为0。
解决:所有BigDecimal必须用字符串构造:new BigDecimal("0.01");数据库字段统一定义为DECIMAL(18,2);JDBC 查询时用resultSet.getBigDecimal("debit"),禁止getDouble()。

4.2 现象:多用户同时提交凭证,出现“凭证号重复”异常,但数据库t_voucher.voucher_no并无重复记录

原因:VoucherNoGenerator.generate()中getMaxSequenceInPeriod()查询与后续INSERT之间存在时间窗口,A 用户查到00001,B 用户也查到00001,两者都生成VOU20240500001X。
解决:在generate()方法内,对t_voucher表加SELECT ... FOR UPDATE锁定期间范围:

SELECT MAX(sequence) FROM t_voucher WHERE voucher_no LIKE 'VOU202405%' FOR UPDATE;

注意:此 SQL 必须在事务内执行,且voucher_no字段需建前缀索引INDEX idx_vno_prefix (voucher_no(10))。

4.3 现象:AuthFilter检查登录态正常,但进入/jsp/finance/voucher_list.jsp后,session.getAttribute("user")返回 null

原因:JSP 页面中使用了<%@ page session="false" %>指令(为提升性能关闭 Session),导致页面无法访问 Session。
解决:财务系统所有 JSP 必须显式声明<%@ page session="true" %>;若需优化,应在web.xml中设置<session-config><session-timeout>30</session-timeout></session-config>,而非关闭 Session。

4.4 现象:导出资产负债表时,ReportExportServlet写入 Excel 流,Tomcat 日志报java.io.IOException: Broken pipe

原因:用户点击导出后立即关闭浏览器,Servlet 仍在向已断开的 Socket 写数据。
解决:在流式写入循环中,每次response.getOutputStream().write()后,检查response.isCommitted(),若为 true 则break退出;并在finally块中try{os.close()}catch(Exception e){}。

4.5 现象:t_voucher.status从draft变为approved后,t_journal_entry分录金额总和与凭证头amount字段不等

原因:前端 JSP 中,分录行通过 JavaScript 动态添加,但提交时未校验最后一行是否为空(account_code为空但debit有值),导致脏数据入库。
解决:VoucherSubmitServlet中,解析request.getParameterValues("account_code[]")后,必须遍历所有数组索引,对每个索引检查account_code[i]、debit[i]、credit[i]是否均非空,任一为空则抛出IllegalArgumentException("分录第"+(i+1)+"行科目代码不能为空")。


5. 事务边界与余额校验:让每一笔钱都经得起审计追问

5.1 凭证过账的“三阶校验”:从提交到落库的完整链路

财务系统的核心不是功能多,而是每一步都有据可查。我们把VoucherSubmitServlet的doPost()拆解为三个原子阶段,每个阶段失败都返回不同 HTTP 状态码,前端据此展示精准提示:

阶段校验内容失败响应审计价值
第一阶:凭证头校验voucher_date是否在会计期间内(查t_accounting_period表)、voucher_type是否匹配分录方向(如“收款”凭证的借方必须含“银行存款”)HTTP 400 Bad Request+ JSON{ "error": "凭证日期超出当前会计期间" }留下voucher_date与period的匹配日志,供审计追踪期间切换是否合规
第二阶:分录平衡校验SUM(debit) == SUM(credit)(浮点容差 ≤ 0.01)、每行account_code是否存在于t_account且is_leaf=1HTTP 400+{ "error": "分录不平衡,请检查第3行" }记录voucher_id和entry_index,定位到具体哪一行分录出错
第三阶:余额充足校验对每笔贷方分录(如“银行存款”),查询t_account_balance.end_balance,确认end_balance >= credit_amountHTTP 403 Forbidden+{ "error": "银行存款余额不足,当前余额: 12500.00" }最关键:此阶段必须SELECT ... FOR UPDATE锁定余额行,防止并发扣减超支

关键代码(第三阶):

// 对凭证中所有贷方分录(type='付款'或'转账'的贷方) for (JournalEntryEntity entry : entries) { if (entry.getCredit().compareTo(BigDecimal.ZERO) > 0) { String code = entry.getAccountCode(); // 加锁查询余额 AccountBalanceEntity balance = balanceDao.findByCodeAndPeriodForUpdate( code, "2024-05", conn); if (balance.getEndBalance().compareTo(entry.getCredit()) < 0) { throw new InsufficientBalanceException( String.format("科目 %s 余额不足,当前: %s, 需: %s", code, balance.getEndBalance(), entry.getCredit())); } } }

注意:findByCodeAndPeriodForUpdate()方法执行的 SQL 是SELECT * FROM t_account_balance WHERE account_code=? AND period=? FOR UPDATE,这会锁定该行,直到事务结束。若 A 用户正在过账,B 用户同时提交涉及同一科目的凭证,B 将阻塞等待,而非读到脏数据。

5.2 操作留痕:不靠日志文件,靠数据库的t_audit_log

财务系统不能只说“谁干了什么”,要说“谁在什么时间、用什么 IP、改了哪个字段、从什么值改成什么值”。我们设计t_audit_log表:

字段类型说明
idBIGINT PK主键
operator_idBIGINT操作人 user_id
operator_ipVARCHAR(45)request.getRemoteAddr()获取(支持 IPv6)
operation_timeDATETIMEnew Timestamp(System.currentTimeMillis())
target_tableVARCHAR(50)如t_voucher,t_journal_entry
target_idBIGINT被操作记录的主键值
actionENUM('INSERT','UPDATE','DELETE')操作类型
old_valuesTEXTJSON 格式,如{"status":"draft","amount":"1000.00"}
new_valuesTEXTJSON 格式,如{"status":"approved","amount":"1000.00"}
remarkVARCHAR(200)人工备注(如“审批通过”)

不是所有操作都记:只记录t_voucher.status变更、t_journal_entry新增/修改、t_account_balance更新。LoginServlet成功登录也记一条(action='LOGIN',old_values=null, `new_values={"username":"zhangsan"})。

在TransactionFilter的doFilter()结尾处,无论成功失败,都调用auditLogService.log():

try { chain.doFilter(request, response); if (response.getStatus() == HttpServletResponse.SC_OK) { auditLogService.logSuccess(request, "VoucherSubmitServlet"); } } catch (Exception e) { auditLogService.logError(request, "VoucherSubmitServlet", e.getMessage()); throw e; }

5.3 一个硬核技巧:用SAVEPOINT实现凭证部分回滚

业务常有这种需求:“这张凭证有 5 行分录,其中第 3 行科目选错了,其他 4 行正确,能否只撤回第 3 行?” 原生 JDBC 支持SAVEPOINT,我们将其封装为VoucherPartialRollbackService:

public void rollbackEntry(long voucherId, int entryIndex) throws SQLException { Connection conn = DataSourceUtil.getConnection(); conn.setAutoCommit(false); // 1. 设置保存点 Savepoint sp = conn.setSavepoint("entry_" + entryIndex); try { // 2. 删除指定分录 journalEntryDao.deleteByVoucherIdAndIndex(voucherId, entryIndex, conn); // 3. 重新计算并更新余额(只针对该分录涉及的科目) JournalEntryEntity entry = getEntryById(voucherId, entryIndex); recalculateBalanceForAccount(entry.getAccountCode(), "2024-05", conn); conn.commit(); } catch (Exception e) { conn.rollback(sp); // 仅回滚到保存点 conn.commit(); // 提交其他操作 throw e; } }
  • 为什么不用@Transactional?因为 Spring 的@Transactional是方法级,无法在方法内部精细控制回滚点。
  • 审计意义:每次rollbackEntry操作,t_audit_log中会新增两条记录:一条action='UPDATE'记录凭证状态变为partial_rollback,一条action='DELETE'记录分录删除详情,old_values和new_values清晰展示变更。

我带过的某高校毕业设计小组,曾用这套SAVEPOINT机制帮一家代账公司修复了 37 张历史凭证的科目误选问题,全程未影响其他凭证状态,审计报告中“操作可逆性”一项拿了满分。财务系统的尊严,不在于它多快,而在于它敢不敢对每一笔钱说:“我随时能证明它来龙去脉”。

希望帮到你。

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

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

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

立即咨询