
简介这是一套基于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;charsetUTF-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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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 ThreadLocalConnection 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(); } }说明这段代码的三个关键点。ThreadLocalConnection保证了同一个线程内的所有 DAO 调用拿到的都是同一个数据库连接。Tomcat 处理请求时一个线程从进入doPost到返回响应整个过程都在同一个线程里执行用 ThreadLocal 传递连接等于把连接“钉”在这次业务调用上多个 DAO 方法之间共享同一个事务边界。getConnection里不直接new Connection而是从连接池获取。连接池的作用是复用物理连接避免频繁创建和销毁数据库连接的开销。一般在DataSourceFactory里配置连接池参数比如initialSize5、maxActive20、maxWait3000这三项分别代表初始连接数、最大连接数和获取连接的最大等待毫秒数。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和ListVoucherItemDO的核心逻辑如下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;charsetUTF-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()); ListVoucherItemDO 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?actionquery); } }这段代码里有几个隐藏边界条件。第一个是日期参数voucherDate来自 JSP 的input typedate浏览器统一按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.DriverMySQL 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse。时区参数没有统一标准的说法但Asia/Shanghai是实际生产环境最常用的不要省略。5.3 JSP 页面中文乱码三层不一致现象页面所有中文显示为问号或者插入数据库的中文变成乱码。原因浏览器请求编码、Servlet 解析编码、数据库存储编码三层不一致。有可能是 JSP 页面本身没有设置pageEncoding或者数据库表用了latin1字符集。解决JSP 文件头部确认三行全有——% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%前端表单用methodpostServlet 里第一行加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)这个方法会把连接归还连接池而不是真正关闭。同时把连接池参数根据机器配置适度调大个人推荐initialSize10、maxActive50、maxIdle20、maxWait3000。如果仍出现耗尽再进一步排查是否事务未提交导致连接被长期占用。血泪经验是绝大多数连接耗尽都是代码漏了finally而不是参数不够。6. 查询优化与数据安全让这套系统跑得更稳的两个进阶习惯6.1 PreparedStatement 预编译财务数据防注入的第一道门财务系统里所有用户输入最终都要拼进 SQL如果直接把字符串拼进 SQL 语句等于把数据库大门敞开。最简单的防注入手段就是用PreparedStatement参数占位同时它还能提升反复执行同类 SQL 的效率。public ListVoucherDO 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 11 --也只会被当作字符串字面量不会被当成 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 层有没有全表扫描的隐患、事务边界是否正确再决定要不要改代码——这个习惯帮我避掉了很多上线前的暗雷希望帮到你。本文还有配套的精品资源点击获取