简介:这是一套基于Java EE原生Servlet与MySQL的企业财务管理系统设计与实现完整资料包,适合毕业设计、课程项目或Java Web学习者使用,解决从系统架构、数据库设计到业务编码的完整落地问题。压缩包共14个文件,包含源码工程、SQL数据库脚本、设计论文、任务书与中期报告、答辩PPT、演示视频和项目截图等,整体大小约117.82MB。目前已有87人学习浏览,资料配套较为齐全,便于对照学习。系统功能覆盖资产类别、经营信息、费用信息、年终分析、部门与职工信息管理、职工工资等核心模块;视频详细演示了项目创建部署、数据库创建及系统登录运行全过程,可帮助读者快速理解源码结构并完成二次开发。
1. 原生 Servlet 写财务系统:为什么这份源码值得你抄下来
上周帮朋友看一套《企业财务管理系统设计与实现》的课程设计源码,意外发现一个反直觉的结论:他同学用 Spring Boot 写的项目因为依赖版本对不上、Tomcat 兼容性出问题,整整调了两天没跑起来;反而是这套用原生 Servlet + MySQL 的老实组合,十分钟建库、五分钟部署,一次通过。如果你正在选 Java 课程设计或毕业设计的项目,担心框架版本地狱,这套原生 Servlet + MySQL 的财务管理系统源码会是个相当稳妥的底子。
这套系统覆盖了登录认证、凭证录入、审核、过账结账、科目管理、用户角色、统计报表这些财务系统的核心链路。没有 Spring、没有 MyBatis,一个 Servlet 对应一个业务入口,DAO 层手写 JDBC,事务用 Connection 手动控制。对新手来说,每一行代码都能看到从 HTTP 请求到数据库记录的全过程;对熟手来说,这种没有框架包装的代码反而最适合用来补底层细节——Servlet 生命周期、Filter 链、JDBC 事务边界、连接池参数,全部裸奔可见。下文我按一次完整跑通项目的顺序,把源码里的关键设计和踩过的坑一条条拆开。
2. 登录与请求链路:Servlet 生命周期和 Filter 拦截的落地写法
2.1 原生 Servlet 的请求处理模型,为什么适合财务系统
先明确一个观点:做课程设计或企业内部小工具,原生 Servlet 不是倒退,而是可控。Spring Boot 帮你省事的同时也把请求进入 Controller 之前的细节全部黑匣子化了,出问题只能靠猜。原生 Servlet 的链路非常直白:Tomcat 启动时加载 Servlet 实例,调用init()做初始化;每个请求到达后,容器分配线程,走service()方法,按请求类型分发到doGet()或doPost();应用关闭时调用destroy()释放资源。
这套财务系统里的 Servlet 集中在controller包下,每个 Servlet 只负责一类业务,例如LoginServlet、VoucherServlet、AccountServlet、ReportServlet。Web.xml 里配置了 Servlet 映射和 Filter 链,如果用 IDEA 打开项目,能清楚看到每个 URL 模式对应的类。这种一对一映射关系有个明显优势:请求从哪来、到哪个类、做什么事,肉眼可查,不需要翻一堆注解和配置。
在init()阶段,项目初始化了数据库连接池和基础配置项,这样做比在每次请求里新建 Connection 高效得多。连接池里的initialSize和maxActive参数设置了初始连接数和最大连接数,保证财务系统在月初结账这种并发小高峰时不会因为连接匮乏而卡死。
2.2 登录拦截的 Filter 实现:白名单与 Session 校验
财务系统里最敏感的操作就是凭证录入和结账,这些接口必须登录后才能访问。项目里用LoginFilter统一处理登录校验,而不是在每个 Servlet 里写重复代码。Filter 的核心逻辑是先判断请求路径是否属于登录页、静态资源、验证码等白名单,如果不在白名单里,就检查 Session 里有没有用户对象。
package com.finance.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.*; import java.io.IOException; @WebFilter(urlPatterns = "/*") public class LoginFilter implements Filter { private static final String[] WHITE_LIST = { "/login", "/static/css/", "/static/js/", "/static/images/" }; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); // 去掉项目上下文路径后做前缀匹配,避免静态资源被误拦截 String path = uri.substring(req.getContextPath().length()); for (String prefix : WHITE_LIST) { if (path.startsWith(prefix)) { chain.doFilter(request, response); return; } } HttpSession session = req.getSession(false); Object user = (session == null) ? null : session.getAttribute("loginUser"); if (user == null) { // 重定向到登录页,而不是转发,防止地址栏停留在受限页面 resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } @Override public void init(FilterConfig config) {} @Override public void destroy() {} }这段代码有两个细节值得注意。第一,req.getSession(false)不会主动创建 Session,如果用户没登录过,容器不会白白生成一个空的 Session 对象,避免 Session 泛滥。第二,未登录时用重定向而非转发,因为转发只是服务端内部的页面跳转,地址栏还停留在受保护路径,用户刷新后会反复触发拦截,且浏览器地址和实际页面不一致,体验很怪。重定向则让浏览器重新发起一次/login.jsp请求,地址栏恢复正确。
2.3 Servlet 里统一处理编码与转发
浏览器以POST方式提交表单时,参数编码取决于页面的charset,而 Servlet 默认按 ISO-8859-1 解析。这套系统在doPost入口强制设置了 UTF-8 编码,并把请求转发到 JSP 页面渲染。下面是从VoucherServlet里抽出的写法:
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); String action = req.getParameter("action"); // action 参数区分保存、审核、删除等不同类型操作 if ("save".equals(action)) { saveVoucher(req, resp); } else if ("audit".equals(action)) { auditVoucher(req, resp); } else if ("delete".equals(action)) { deleteVoucher(req, resp); } else { req.setAttribute("errorMsg", "Unsupported action: " + action); req.getRequestDispatcher("/voucherForm.jsp").forward(req, resp); } }关于这里的action参数分发模式,简单业务场景下比 RESTful 风格更容易理解和调试。请求的入口统一,参数可以全放在request里,JSP 里用${param.action}能直接拿到。缺点是doPost方法会膨胀,当超过三四种操作时,建议把action判断拆到独立的处理器里,避免单个方法几百行。
3. 数据库设计:财务系统的表结构也能被 MySQL 优化到极致
3.1 科目表、凭证表、凭证明细表的关系设计
财务系统不同于普通 CRUD 项目,它有一套完整的数据约束逻辑。底层先用四张核心表支撑:用户表、科目表、凭证表、凭证明细表。建库脚本在 SQL 文件里,导入后直接可用。我拆解一下最核心的建表语句。
CREATE DATABASE IF NOT EXISTS finance_db DEFAULT CHARACTER SET utf8mb4; USE finance_db; CREATE TABLE t_account ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_code VARCHAR(20) NOT NULL COMMENT '科目编码,如1001库存现金', subject_name VARCHAR(50) NOT NULL COMMENT '科目名称', parent_code VARCHAR(20) DEFAULT NULL COMMENT '父级科目编码', direction TINYINT NOT NULL COMMENT '1=借方 2=贷方', status TINYINT DEFAULT 1, UNIQUE KEY uk_subject_code (subject_code) ) ENGINE=InnoDB COMMENT='会计科目表'; CREATE TABLE t_voucher ( id BIGINT AUTO_INCREMENT PRIMARY KEY, voucher_no VARCHAR(30) NOT NULL COMMENT '凭证号,如记-20240612-001', voucher_date DATE NOT NULL, period VARCHAR(7) NOT NULL COMMENT '会计期间,如2024-06', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已审核 2已过账', audit_user BIGINT DEFAULT NULL COMMENT '审核人id', create_user BIGINT NOT NULL COMMENT '制单人id', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_voucher_no (voucher_no) ) ENGINE=InnoDB COMMENT='记账凭证表'; CREATE TABLE t_voucher_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, voucher_id BIGINT NOT NULL COMMENT '关联凭证主表id', summary VARCHAR(200) COMMENT '摘要', account_id BIGINT NOT NULL COMMENT '会计科目id', debit_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '借方发生额', credit_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '贷方发生额', KEY idx_voucher_id (voucher_id) ) ENGINE=InnoDB COMMENT='凭证明细行';这里刻意提到了两个设计决策。
金额类型必须用DECIMAL(14,2),而不是FLOAT或DOUBLE。二进制浮点数在累加时会产生精度偏移,比如 0.1 + 0.2 在浮点表示里是一个近似值。财务凭证一旦涉及多行明细、跨月累计,浮点误差会在报表里放大,对账时出现几分钱的差异会非常难受。DECIMAL是字符串存储的数字类型,MySQL 计算时按定点数处理,精度可控。
科目编码用唯一索引约束。企业财务里科目编码是全局唯一的,1001 代表库存现金,1002 代表银行存款。编码重复会导致凭证摘要混乱,而且很难追溯。加了唯一索引后,插入重复编码会直接报错,从数据库层面兜住程序里漏掉的校验。
t_voucher_item的voucher_id建了普通索引而不是唯一索引,因为一个凭证必然多条明细,一对多关系,不需要唯一约束。但也提醒一点:查询一张凭证的明细时,SQL 会走idx_voucher_id索引,而不是全表扫描。
3.2 事务控制:JDBC 的 Connection 绑定业务线程
财务系统录入凭证最重要的一点是“要么全成功,要么全失败”。一张凭证包含主表一条记录和明细表多条记录,如果主表插入了、明细插入时网络闪断,数据库里就出现“无头凭证”,月底结账平不了。原生 JDBC 的事务控制要由开发者自己管理Connection,这套项目的关键在于把连接绑定到当前线程。
public class DBUtil { private static final ThreadLocal<Connection> CONN_HOLDER = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = CONN_HOLDER.get(); if (conn == null || conn.isClosed()) { conn = DataSourceFactory.getDataSource().getConnection(); CONN_HOLDER.set(conn); } return conn; } public static void release(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException e) { // 忽略关闭异常 } } CONN_HOLDER.remove(); } public static void beginTransaction() throws SQLException { Connection conn = getConnection(); conn.setAutoCommit(false); } public static void commit() throws SQLException { getConnection().commit(); } public static void rollback() throws SQLException { getConnection().rollback(); } }说明这段代码的三个关键点。
ThreadLocal<Connection>保证了同一个线程内的所有 DAO 调用拿到的都是同一个数据库连接。Tomcat 处理请求时一个线程从进入doPost到返回响应,整个过程都在同一个线程里执行,用 ThreadLocal 传递连接,等于把连接“钉”在这次业务调用上,多个 DAO 方法之间共享同一个事务边界。
getConnection里不直接new Connection,而是从连接池获取。连接池的作用是复用物理连接,避免频繁创建和销毁数据库连接的开销。一般在DataSourceFactory里配置连接池参数,比如initialSize=5、maxActive=20、maxWait=3000,这三项分别代表初始连接数、最大连接数和获取连接的最大等待毫秒数。maxWait设置为 3000 毫秒比较合理,超过 3 秒拿不到连接直接抛异常,提醒你数据库或连接池出了问题,而不是让请求无限阻塞。
在 Service 层使用事务的完整写法是:先记录凭证主表,再循环插入每一条明细,插入过程中一旦发生异常就调用rollback()回滚全部操作,正常结束则commit()。注意是把beginTransaction()、commit()、rollback()放在 Service 层,而不是 Servlet 层。Servlet 只负责参数解析和页面跳转,事务是业务逻辑责任,不应该被 HTTP 层绑架。这套代码在 Service 层完成了上述流程,DAO 只负责把 SQL 送到数据库,不管理事务边界。
4. 核心业务流程拆解:凭证录入、审核、结账的代码实现
4.1 凭证录入的表单结构与参数组装
凭证录入界面在voucherForm.jsp中,前端表格每一行代表一条明细,包含摘要、科目下拉框、借方金额、贷方金额、备注。页面上有一个“添加行”按钮,用 JavaScript 动态插入新行,并把行号递增写入行内隐藏域。提交时,多条明细通过同名参数传至后台,例如摘要字段名为summary,后台用request.getParameterValues("summary")拿到字符串数组,与科目、金额一一对应。
组装参数这一步容易出错,尤其是空行过滤。常见做法是在前端提交前校验,删除金额全为空的行,避免后台解析到null值。后台组装VoucherDO和List<VoucherItemDO>的核心逻辑如下:
public class VoucherServlet extends HttpServlet { private VoucherService voucherService = new VoucherService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); try { String action = req.getParameter("action"); if ("save".equals(action)) { saveVoucher(req, resp); } else if ("audit".equals(action)) { auditVoucher(req, resp); } } catch (Exception e) { req.setAttribute("errorMsg", e.getMessage()); req.getRequestDispatcher("/voucherForm.jsp").forward(req, resp); } } private void saveVoucher(HttpServletRequest req, HttpServletResponse resp) throws Exception { VoucherDO voucher = new VoucherDO(); voucher.setVoucherDate(java.sql.Date.valueOf(req.getParameter("voucherDate"))); // status 初始为草稿 0,审核后才能过账 voucher.setStatus(0); voucher.setCreateUser(((UserDO) req.getSession().getAttribute("loginUser")).getId()); List<VoucherItemDO> items = new ArrayList<>(); String[] summaries = req.getParameterValues("summary"); String[] accountIds = req.getParameterValues("accountId"); String[] debitAmounts = req.getParameterValues("debitAmount"); String[] creditAmounts = req.getParameterValues("creditAmount"); for (int i = 0; i < summaries.length; i++) { // 跳过完全空白的行,避免插入空摘要、金额为0的无效明细 if ((summaries[i] == null || summaries[i].isEmpty()) && "0".equals(debitAmounts[i]) && "0".equals(creditAmounts[i])) { continue; } VoucherItemDO item = new VoucherItemDO(); item.setSummary(summaries[i]); item.setAccountId(Long.parseLong(accountIds[i])); item.setDebitAmount(new BigDecimal(debitAmounts[i])); item.setCreditAmount(new BigDecimal(creditAmounts[i])); items.add(item); } voucherService.saveVoucher(voucher, items); resp.sendRedirect(req.getContextPath() + "/voucherList?action=query"); } }这段代码里有几个隐藏边界条件。第一个是日期参数voucherDate来自 JSP 的<input type="date">,浏览器统一按yyyy-MM-dd字符串传递,所以用Date.valueOf()直接转换,不用SimpleDateFormat做格式解析,避免格式异常。第二个是金额统一用new BigDecimal(String)构造,不是new BigDecimal(double),因为 double 构造方式会出现 0.1 变成 0.100000000000000005 的精度问题,String 构造则严格按照字符转换。
凭证保存后系统要立刻做“借贷必相等”的校验,具体逻辑是SELECT SUM(debit_amount), SUM(credit_amount) FROM t_voucher_item WHERE voucher_id = ?,两个总和用compareTo比较,相等才能继续,否则抛出业务异常。注意这里不能用equals比较两个 BigDecimal,因为0.00和0的 scale 不同,equals会返回 false。
4.2 审核与结账:状态流转的 SQL 控制
凭证保存后是草稿状态,只有进入“已审核”状态才能被结账纳入月度报表。审核操作在VoucherServlet.audit中执行,角色权限要求在页面层限制只有“财务主管”能看到审核按钮。审核的 SQL 非常简单:
UPDATE t_voucher SET status = 1, audit_user = ? WHERE id = ? AND status = 0这里强制带上AND status = 0条件。这样做的好处是防止并发重复审核——两个人同时打开一张草稿凭证,都点了审核,数据库层面只有一个更新成功,另一个受影响行数为 0,程序据此判断“该凭证已被他人审核”,简单而有效。如果去掉这个条件,第二次审核仍会更新成功,审核人字段被覆盖,不符合财务操作留痕的要求。
结账的流程是一个 SQL 事务,把当月所有已审核凭证批量置为过账状态,同时把当月明细数据归档到月度余额表。
-- 结账前检查当月是否存在未审核凭证,存在则结账失败 SELECT COUNT(*) FROM t_voucher WHERE period = ? AND status IN (0, 1); -- 全部已审核,执行过账 UPDATE t_voucher SET status = 2 WHERE period = ? AND status = 1; -- 插入月度余额归档表,用于报表查询时避免扫描全部明细 INSERT INTO t_period_balance (period, account_id, debit_total, credit_total) SELECT ?, account_id, SUM(debit_amount), SUM(credit_amount) FROM t_voucher_item i JOIN t_voucher v ON i.voucher_id = v.id WHERE v.period = ? AND v.status = 2 GROUP BY account_id;归档表的意义在于把当月发生额提前算好,月底打开利润表时直接查汇总表,而不是每次实时扫描几万条明细再聚合。这是财务系统里典型的“用空间换时间”设计,你以后做报表查询都可以借鉴。
4.3 制单与审核为什么必须分离
这套系统设计了两种角色:制单员、财务主管。制单员能录入凭证但不能审核自己录入的凭证,财务主管能看到全部凭证数据。这是一条财务合规的底线要求——同一人既做凭证又做审核,会产生舞弊空间。代码层面在审核逻辑里加了一行校验:如果voucher.create_user == currentUser.getId(),直接抛出“不能审核自己制单的凭证”。这个约束用一句代码实现,但体现的设计意识比很多 CRUD 项目高一个层次。
5. 避坑记录:从 Tomcat 版本到 MySQL 时区,五个高频雷区
5.1 Tomcat 10 与 javax.servlet 包名不兼容
现象:把源码部署到 Tomcat 10 后启动报错,提示java.lang.NoClassDefFoundError: javax/servlet/ServletException或jakarta/servlet/ServletException。
原因:Tomcat 9 及之前版本使用javax.servlet作为 Servlet API 包名,Tomcat 10 开始迁移到jakarta.servlet。源码基于 javax 编写,放到 Tomcat 10 上找不到对应类。
解决:使用 Tomcat 9.x,不要用 Tomcat 10。如果必须用 Tomcat 10,需要把代码里所有javax.servlet全局替换为jakarta.servlet,注意这是批量替换,涉及 import 和注解。这个坑是最常见的,十个人里至少有三个人栽在这。
5.2 mysql-connector-java 驱动版本与 MySQL 8.0 不匹配
现象:连接数据库时报ClassNotFoundException: com.mysql.jdbc.Driver,或报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。
原因:MySQL 5.x 的驱动类名是com.mysql.jdbc.Driver,MySQL 8.0 将驱动类改名为com.mysql.cj.jdbc.Driver。部分旧版源码引用的还是老驱动,需要更新。
解决:在WEB-INF/lib下放置mysql-connector-java-8.0.33.jar,同时 JDBC URL 增加时区参数:jdbc:mysql://localhost:3306/finance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。时区参数没有统一标准的说法,但Asia/Shanghai是实际生产环境最常用的,不要省略。
5.3 JSP 页面中文乱码三层不一致
现象:页面所有中文显示为问号,或者插入数据库的中文变成乱码。
原因:浏览器请求编码、Servlet 解析编码、数据库存储编码三层不一致。有可能是 JSP 页面本身没有设置pageEncoding,或者数据库表用了latin1字符集。
解决:JSP 文件头部确认三行全有——<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>,前端表单用method="post",Servlet 里第一行加req.setCharacterEncoding("UTF-8"),建库时用utf8mb4。项目脚本里建库语句已经用utf8mb4,如果导入本地后改过表结构,要重新确认连接参数里没有characterEncoding被删掉。
5.4 IDEA 部署后 JSP 页面 404
现象:Tomcat 能启动,但访问http://localhost:8080/项目名/voucherList报 404,控制台无报错。
原因:IDEA 的 Artifact 配置里没有把 JSP 目录打进部署包,或者 Web 资源根目录指错了位置,只打包了WEB-INF/classes而没有把webapp下的 JSP 文件打进去。
解决:打开 Project Structure → Artifacts,确认该 Artifact 的 “Output Layout” 里有整个webapp目录,且类型为 “Web Resource Directory”。如果没有,右键添加 “Directory Content”,选中项目里的webapp文件夹。操作完成后重新构建 Artifact,再重启 Tomcat。
5.5 连接池参数导致的高并发下连接耗尽
现象:系统运行一段时间后,页面偶尔出现Cannot get a connection, pool exhausted错误,重启 Tomcat 后恢复正常。
原因:连接池的maxActive设置过小,或者代码中获取Connection后没有在finally块里释放,导致连接泄漏。
解决:检查所有 DAO 方法,确认finally中调用了DBUtil.release(conn),这个方法会把连接归还连接池而不是真正关闭。同时把连接池参数根据机器配置适度调大,个人推荐initialSize=10、maxActive=50、maxIdle=20、maxWait=3000。如果仍出现耗尽,再进一步排查是否事务未提交导致连接被长期占用。血泪经验是:绝大多数连接耗尽都是代码漏了finally,而不是参数不够。
6. 查询优化与数据安全:让这套系统跑得更稳的两个进阶习惯
6.1 PreparedStatement 预编译,财务数据防注入的第一道门
财务系统里所有用户输入最终都要拼进 SQL,如果直接把字符串拼进 SQL 语句,等于把数据库大门敞开。最简单的防注入手段就是用PreparedStatement参数占位,同时它还能提升反复执行同类 SQL 的效率。
public List<VoucherDO> queryByVoucherNo(String voucherNo) throws SQLException { String sql = "SELECT * FROM t_voucher WHERE voucher_no = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 参数绑定,而不是字符串拼接 ps.setString(1, voucherNo); try (ResultSet rs = ps.executeQuery()) { // 从 ResultSet 里逐行封装成 VoucherDO 对象 } } }?占位符会让 MySQL 把这条 SQL 做预编译,后续相同结构、不同参数的查询可以直接复用执行计划,比每次拼接字符串再跑来重新解析 SQL 要快不少。从安全角度看,?占位符传入的值只会被当作字符数据,即使包含' OR 1=1 --也只会被当作字符串字面量,不会被当成 SQL 关键字执行。这套系统里所有涉及用户输入的查询都采用这种写法,没有一处字符串拼接 SQL 的痕迹。
6.2 报表查询的索引优化,避免月末结账卡顿
财务系统的月末报表查询往往要关联t_voucher和t_voucher_item两张表,数据量大时全表扫描会拖垮数据库。这时记得给关联字段和外键建索引。推荐一个实际验证过的复合索引组合:
ALTER TABLE t_voucher ADD INDEX idx_period_status (period, status);这个索引直接服务于结账时的WHERE period = ? AND status IN (0,1)查询。只查period或者只查status时,MySQL 也可以用最左前缀规则部分走这个索引。凭证查询如果经常按日期范围过滤,也可以给voucher_date加个普通索引,但不用每列都建索引,索引过多反而拖慢插入性能。
除此之外,报表页面可以借用月度余额归档表t_period_balance做二次聚合,避免每次打开利润表都要扫一遍全部历史明细。查询时先查归档表,再回明细表核对,响应速度会有肉眼可见的提升。从那以后我每次拿别人的 Java Web 项目,都会先检查 DAO 层有没有全表扫描的隐患、事务边界是否正确,再决定要不要改代码——这个习惯帮我避掉了很多上线前的暗雷,希望帮到你。
本文还有配套的精品资源,点击获取