简介:一套面向Java EE课程设计、毕业设计及中小企业财务信息化初学者的完整项目资料包,围绕原生Servlet与MySQL技术栈,呈现企业财务管理系统从设计、开发到部署的全过程。资源共14个文件,压缩包约117.82MB,包含项目源码、数据库SQL脚本、论文与中期报告/任务书、毕业设计答辩PPT、系统界面截图,以及三段MP4辅导视频,分别讲解项目创建部署、数据库初始化、部门/职工/工资、资产/费用/经营/年终分析等模块的操作演示。目前已有88人学习下载。借助这套资料,读者可快速搭建可运行的企业财务管理系统,理解资产、负债、成本、财务分析等模块的划分逻辑,掌握Servlet请求处理、MySQL数据持久化、报表生成与预算控制等核心实现;源码、SQL、文档、视频相互对应,既能用于查漏补缺,也方便二次扩展,是一份适合实训与答辩场景的完整参考。
1. 原生Servlet做企业财务系统:不依赖框架也能稳定记账
“企业财务管理系统”听起来需求复杂,但落到代码层面,用原生 Servlet + MySQL 这一套最传统的 Java Web 组合,完全能把记账、凭证、报表的骨架撑起来。相反,一上来就堆 Spring Boot、微服务,对这类以表单和查询为主的内部系统往往是过度设计。这个标题的含金量在于两点:一是数据库设计要按会计习惯来,而不是简单建张流水表;二是 Servlet/JSP 没有框架帮你收口,事务、并发、编码全得自己盯。谁适合读?接手传统 Java Web 项目的人、想弄清财务模块到底怎么设计的开发者、需要快速交付“够用且不易出账”的内部系统的团队。这篇按一个可复现的完整方案讲:从库表到会话权限,再到记账事务与部署排坑。
2. 从需求到表结构:财务数据的核心库表与关键字段
财务系统最忌讳“数据库结构跟着页面走,缺字段就加字段”。页面好改,表结构一旦乱了,后面的报表、对账、查询全得跟着返工。所以先把数据模型定清楚,再做 Servlet 功能。下面这套表结构不是凭空设计的,是从企业财务的实际操作流程里拆出来的。
2.1 科目、凭证与分录:先定三张主表
常见做法是建四张表:科目表、凭证头表、凭证分录表,外加一张用户表。科目表存“库存现金”“银行存款”“主营业务收入”这类会计科目,统一编码,后面查询和统计都靠科目代码而不是字符串匹配。凭证头表存一张凭证的整体信息:凭证号、日期、制单人、审核状态。凭证分录表则存这张凭证下的每一条借贷记录。
为什么凭证要拆成头表和分录表?因为一张凭证往往有多条分录,比如“借:银行存款 1000,贷:主营业务收入 1000”,一借一贷;再复杂一点还有一借多贷、多借多贷。如果不拆表,直接在凭证表里塞多行,凭证号、日期、制单人会被大量重复存储,审核状态也不好维护。拆开之后,头表存状态,分录表存明细,审核时只需要 UPDATE 头表,统计数据时只需要 JOIN 两张表。
这里还要提醒一个财务系统独有的习惯:凭证不允许物理删除,只能“作废”。也就是说 t_voucher 的状态字段要预留一个“已作废”值,而不是查出数据后直接 DELETE。原因很简单,财务需要留痕,审计要能追溯原始单据,物理删掉就什么证据都没了。这跟普通业务系统完全不一样,设计时就要想清楚。
2.2 金额为什么选 DECIMAL(18,2) 而不是 DOUBLE
如果没被金额精度坑过,很容易随手把金额字段设成 DOUBLE,这是个非常典型的翻车点。DOUBLE 是二进制浮点数,0.1 + 0.2 在 double 里算出来不是精确的 0.3。平时单笔看不出来,一旦累计上千条流水,对账差出一分钱,财务人员能把整月凭证翻一遍。
所以金额字段必须用 DECIMAL(18,2),Java 侧接收也必须用 BigDecimal,不要用 double 去接。数据库的 SUM 函数也一样:对 DECIMAL 字段求和,结果类型仍是 DECIMAL;对 DOUBLE 字段求和,精度损失会被放大。特别是月底出科目余额表时,SUM 出来一个 0.30000000000000004 之类的值,那场面相当难收场。
如果涉及单价乘数量,中间计算需要小数位更多,建议中间字段用 DECIMAL(18,4),展示给用户时再四舍五入到 2 位。这样比一开始就 DECIMAL(18,2) 丢失的精度少很多。别担心 DECIMAL 比 DOUBLE 占空间,财务系统的数据量远没有到需要考虑那点存储成本的程度。
2.3 初始化 SQL:结构脚本与种子数据一个文件跑通
直接给一份能用的建表脚本。设计上没有追求过度复杂,但索引、字符集、外键都按生产习惯带了。
CREATE DATABASE IF NOT EXISTS finance_db DEFAULT CHARSET utf8mb4; USE finance_db; -- 会计科目表 CREATE TABLE t_subject ( id INT PRIMARY KEY AUTO_INCREMENT, subject_code VARCHAR(20) NOT NULL UNIQUE COMMENT '科目编码', subject_name VARCHAR(64) NOT NULL COMMENT '科目名称', direction CHAR(1) NOT NULL DEFAULT 'D' COMMENT 'D-借方 C-贷方', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计科目表'; -- 凭证头表 CREATE TABLE t_voucher ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(32) NOT NULL UNIQUE COMMENT '凭证号', voucher_date DATE NOT NULL COMMENT '凭证日期', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未审核 1-已审核 2-已作废', creator_id INT NOT NULL COMMENT '制单人ID', checker_id INT DEFAULT NULL COMMENT '审核人ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, check_time DATETIME DEFAULT NULL, KEY idx_voucher_date (voucher_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证头表'; -- 凭证分录表 CREATE TABLE t_voucher_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_id BIGINT NOT NULL COMMENT '凭证头表ID', subject_id INT NOT NULL COMMENT '科目ID', summary VARCHAR(128) DEFAULT NULL COMMENT '摘要', direction CHAR(1) NOT NULL COMMENT 'D-借方 C-贷方', amount DECIMAL(18,2) NOT NULL COMMENT '金额', KEY idx_item_voucher (voucher_id), CONSTRAINT fk_item_voucher FOREIGN KEY (voucher_id) REFERENCES t_voucher(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证分录表'; -- 用户表 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password CHAR(64) NOT NULL COMMENT 'SHA-256 加盐哈希', real_name VARCHAR(32) NOT NULL, role VARCHAR(16) NOT NULL DEFAULT 'accountant' COMMENT 'accountant-录入 auditor-审核 admin-管理员' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; -- 初始科目,按需再扩展 INSERT INTO t_subject (subject_code, subject_name, direction) VALUES ('1001', '库存现金', 'D'), ('1002', '银行存款', 'D'), ('6001', '主营业务收入', 'C');执行时顺序不能乱:先建科目表,再建凭证头表,最后建分录表,因为分录表的外键要引用凭证头表。字符集统一用 utf8mb4,MySQL 里的 utf8 不是完备的 UTF-8,生僻字和部分符号存不进去或乱码,这个坑碰到一次就得记住。凭证号字段直接加 UNIQUE 约束,并发生成凭证时可以靠数据库兜底,后面第 5 章会专门展开讲。
3. Servlet + JDBC 的骨架代码:连接管理、登录会话与权限开关
原生 Servlet 体系下,Controller 就是一个继承 HttpServlet 的类,在 doGet/doPost 里写业务逻辑。没有自动注入、没有切面,所有资源管理都得自己收口。这一章先把整个系统最基础也是最容易出问题的两块讲清楚:数据库连接怎么管、登录和权限怎么拦。
3.1 手写JDBC工具类的边界:演示版与生产版怎么切换
先给一个最小可用的 JDBC 工具类。这段代码适合单机学习、课程设计、小并发内部系统直接用,但生产环境要换连接池。
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/finance_db" + "?characterEncoding=utf8&useSSL=false" + "&serverTimezone=Asia/Shanghai"; private static final String USER = "finance_app"; private static final String PASSWORD = "change_me"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动不存在,请检查依赖", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement st, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (st != null) { try { st.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }逻辑说明:静态代码块负责加载驱动,DriverManager 每次调用都创建一条新的物理连接,并发一高就吃力,且没有连接复用。close 方法把 ResultSet、Statement、Connection 三样都收口,调用方在 finally 里一行就能关闭所有资源。
参数说明:连接地址里的 characterEncoding=utf8 必须带上,这是数据库端中文不乱码的前置条件。serverTimezone 是 MySQL 8 驱动的要求,不设容易报时区异常。生产环境请把这段换成连接池,常见做法是用 Druid 或 HikariCP。切连接池时有三个参数必须认真调:initialSize 是启动时预建的连接数,maxActive 是最大连接数,maxWait 是拿不到连接的等待毫秒数。maxActive 不是越大越好,盲目调到 200,数据库本身可能先扛不住,一般内部系统 20~50 就够。
3.2 登录过滤器与角色校验:最薄的一层权限控制
财务系统至少要两种角色:录入员只能做凭证,审核员才能复核。最简单可靠的做法是写一个 Servlet Filter,在请求进入业务 Servlet 之前统一做登录判断和角色校验。
public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 放行登录页与静态资源 if (uri.endsWith("/login") || uri.endsWith("/login.jsp") || uri.contains("/static/")) { chain.doFilter(req, resp); return; } // 未登录统一重定向 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 角色校验:审核接口只允许 auditor 角色访问 String path = request.getServletPath(); if (path.startsWith("/audit") && !"auditor".equals(session.getAttribute("role"))) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问"); return; } chain.doFilter(req, resp); } }逻辑说明:getSession(false) 表示不自动创建 session,避免爬虫或试探请求把一堆无用会话堆在内存里。角色校验放在 Filter 里,比在每个 Servlet 里重复判断省事得多。这里只演示了 /audit 路径的判断,实际项目可以把“路径与角色”的映射抽成一个配置,减少硬编码。
登录 Servlet 的核心逻辑也不复杂:查用户、验密码、写 session。密码不要明文存数据库,建议 SHA-256 加盐哈希。登录失败统一提示“用户名或密码错误”,不要区分“用户不存在”,否则等于把账号是否存在告诉别人。
会话超时时间也别忘了在 web.xml 里配一下,财务系统建议 30 分钟以内,比默认的 20 分钟稍长一点,但不要太长,避免审核人员离开工位后会话还挂着。
4. 把财务模块真正落地:记账事务、分页查询与简易导出
页面表单好写,真正有门槛的是记账事务、列表分页、报表导出。这一章按“录入凭证 → 查询列表 → 导出报表”这条主线,把三个环节里最容易出问题的地方逐个讲透。
4.1 记账与复核:为什么必须在同一个事务里写头表和分录表
做凭证时,需要同时向 t_voucher 插一条头表数据、向 t_voucher_item 插多条分录数据。如果先插头表成功、插第二条分录失败,数据库里就会出现一张“有头无身”的凭证。月底对账时这种脏数据非常难排查。所以记账操作必须包在一个数据库事务里,全部成功才提交,任何一步失败整体回滚。
public void saveVoucher(VoucherVO vo) throws SQLException { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 插入凭证头,拿到自增主键 long voucherId = insertVoucherHead(conn, vo); // 2. 循环插入分录 for (VoucherItem item : vo.getItems()) { insertVoucherItem(conn, voucherId, item); } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn, null, null); } }逻辑说明:setAutoCommit(false) 之后,这条连接上的所有 SQL 都不会自动提交,直到手动调用 commit。catch 里回滚,finally 里恢复自动提交并关闭连接。这个写法要把 close 放最后,顺序反了连接就关不掉了。
插入头表后拿自增主键这一步很容易漏,要这样写:prepareStatement 时把 Statement.RETURN_GENERATED_KEYS 传进去,执行 update 后再从 getGeneratedKeys 里取。取到 voucherId 之后再插入分录,外键才不会悬空。
复核操作也有讲究:不能无条件 UPDATE。必须带 status=0 条件,写成UPDATE t_voucher SET status=1, checker_id=?, check_time=NOW() WHERE id=? AND status=0,然后检查更新行数。如果更新行数为 0,说明这张凭证已经被别人审核过了,直接提示“重复审核”。这是并发下最常见的逻辑漏洞,不加条件两个审核员可以同时通过同一张凭证。
4.2 大流水列表页:LIMIT 分页的三种写法与参数
财务系统的凭证列表要按月份查询,数据量从几千到几十万都可能。千万不要把全表数据查出来再在内存里截取,更不要在循环里逐条查,SQL 层就该用 LIMIT 分页。
int page = Integer.parseInt(request.getParameter("page")); int pageSize = 20; String sql = "SELECT v.id, v.voucher_no, v.voucher_date, v.status, " + "u.real_name AS creator_name " + "FROM t_voucher v JOIN t_user u ON v.creator_id = u.id " + "ORDER BY v.voucher_date DESC, v.id DESC " + "LIMIT ?, ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, (page - 1) * pageSize); ps.setInt(2, pageSize);参数说明:offset 是 (page - 1) * pageSize,page 从 1 开始。ORDER BY 必须加,并且要加 id 作为第二排序键,否则同一天的凭证顺序不稳定,翻页时会出现记录重复或遗漏。LIMIT ?, ? 是 MySQL 全版本兼容的写法,MySQL 8.0 也可以用 OFFSET x ROWS FETCH NEXT x ROWS ONLY,但内部系统没必要为这个折腾兼容性。
深分页是个隐藏问题:page 很大时,LIMIT 100000, 20 还是要扫描前十万条,性能会明显下降。常见做法是限制最大可翻页数,比如超过 200 页就强制用户缩小日期范围。财务查询本来就该带月份筛选,不允许无边界地往后翻,这个约束对业务来说是合理的。
4.3 导出CSV:不引第三方库也能满足九成报表需求
审计或对账时经常要把凭证列表导出来。为了保持原生、不引 POI,用 CSV 输出就够了,Excel 打开 CSV 没有任何门槛。核心响应用下面这段:
response.setContentType("text/csv; charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=voucher_2025.csv"); PrintWriter out = response.getWriter(); out.println("凭证号,日期,制单人,金额,状态"); try (ResultSet rs = executeQuery( "SELECT ... FROM ... WHERE voucher_date >= ? AND voucher_date < ?")) { while (rs.next()) { out.println(rs.getString("voucher_no") + "," + rs.getString("voucher_date") + "," + rs.getString("creator_name") + "," + rs.getBigDecimal("amount") + "," + rs.getInt("status")); } }这里有三个细节不是随便写写就能过的。第一,CSV 字段可能包含逗号或换行,直接拼行会把整个文件弄“破行”。解决办法是给每个字段加双引号包裹,字段内部的英文双引号替换成两个双引号,这是 Excel 的转义规则。第二,中文文件名在下载时容易乱码,可以给 header 里的文件名做一下编码处理,或者干脆用英文文件名。第三,大数据量导出时,JDBC 默认会一次性把整个结果集加载到内存,导出十万行可能直接 OOM。正确做法是在连接 URL 加 useCursorFetch=true,再在语句上设置 fetchSize=100,让 MySQL 服务端分批返回数据。这个是黑匣子中的黑匣子,不查资料很难发现,踩过一次就知道要提前写进去。
5. 原生Servlet财务系统避坑指南:5条压箱底排查记录
这套系统做完并不代表能稳定跑,真正拉开差距的是上线前能不能把坑提前填掉。以下 5 条是我反复遇到和听同行吐槽最多的排查记录,按“现象 → 原因 → 解决”整理。
5.1 页面中文全部变问号:乱码不止是 Filter 的锅
现象:本地开发正常,部署到某台服务器后,页面所有中文都变成问号,另一台服务器又完全正常。明明是同一套源码,表现却不一样,非常玄学。
原因:MySQL 连接 URL 没有显式指定 characterEncoding=utf8,同时表或库的字符集又不是 utf8mb4。乱码通常是多个因素叠加的结果,只改 Filter 或只改 JSP 页面都没用。
解决:按三层顺序排查:连接 URL 加 characterEncoding=utf8;建库建表统一用DEFAULT CHARSET utf8mb4;Servlet 和 JSP 页面统一 UTF-8 响应。验证方法是在登录后向一张临时表插入一条中文数据,再 SELECT 出来看显示是否正常,哪一环断了就修哪一环。
5.2 连接泄漏导致系统假死
现象:系统运行几小时后,日志开始报“Too many connections”,页面查询卡死,必须重启 Tomcat 才能恢复,重启后过几小时又复现。
原因:代码里 getConnection 之后在 return 之前忘记关闭,尤其是异常分支。内部系统经常发生这种泄漏,只是个别人写了段不规范的代码,连接累积到连接池上限,数据库就拒绝新连接。
解决:写一个 DBUtil.close 方法,在 finally 里统一关闭 conn、ps、rs;连接池的 maxWait 要设一个合理值,比如 30000 毫秒,拿不到连接时快速失败而不是无限等待。上线后用SHOW STATUS LIKE 'Threads_connected';观察连接数变化,如果持续上升,就需要排查具体是哪个 Servlet 漏关连接。
5.3 并发生成凭证号重复:唯一约束兜底
现象:两个录入员同时保存凭证,生成的凭证号出现重复。没加唯一约束前系统也能跑,但凭证号重复后,审计对账全乱,问题是随机的,不好复现。
原因:凭证号在 Java 代码里按时间戳拼接,比如 “202501-0001”,同一毫秒内并发生成的号一模一样。内存里没有任何锁保护这个拼号逻辑。
解决:先把 t_voucher.voucher_no 建成 UNIQUE 索引;生成号时循环尝试插入,遇到唯一键冲突就换下一个号重试,靠数据库约束兜底。更稳的做法是单独建一张 sequence 表,每取一个号都做行级锁更新,但实现成本略高。加 UNIQUE 约束是最低成本的后悔药,不管代码里怎么拼,数据库都会拦一道。
5.4 BigDecimal 除以一个数直接抛异常
现象:算月度平均金额时,BigDecimal.valueOf(...).divide(...) 抛 ArithmeticException: Non-terminating decimal expansion。代码看起来没毛病,纯数值运算,怎么会抛异常。
原因:BigDecimal 的 divide 方法在除不尽且没有指定精度时,会直接抛异常,这是 JDK 的强制行为,不是 bug,只是很多人第一次遇到会觉得莫名奇妙。
解决:除法必须指定 scale 和 RoundingMode,例如total.divide(BigDecimal.valueOf(12), 2, RoundingMode.HALF_UP)。财务模块里所有金额计算统一用 HALF_UP,也就是四舍五入,不要用银行家舍入,除非有明确的对账规则要求。
5.5 大数据量导出 OOM 与 CSV 串行
现象:点“导出全年数据”,Tomcat 内存瞬间飙升,甚至进程崩溃;导出文件用 Excel 打开后,原本一条记录变成了两行。
原因:默认 ResultSet 会把全部数据拉进客户端内存,10 万行分录很容易超百 MB;CSV 字段里有逗号或换行没有做转义,Excel 解析时把字段内容当成分隔符。
解决:连接 URL 加 useCursorFetch=true,导出前显式 fetchSize=100,用流式取出数据;CSV 字段统一加双引号包裹,内部双引号替换成两个。导出完成后用 Excel 打开检查一下首行和末行,就能确认转义是否完整。
6. 让这套老架构再跑5年:后续升级、验证与维护习惯
原生 Servlet 系统不做重构也能活得很好,关键是把维护成本和风险控制下来。我建议按下面的顺序补,不要急着换框架。
6.1 老系统升级:先补监控与索引,再谈重构
优先做三件事:开启慢查询日志,把 long_query_time 设为 2 秒,定期看有没有索引没走;给 t_voucher_item 的 voucher_id 索引和 t_voucher 的 voucher_date 索引确认一下,没有就补上;把关键操作日志打出来,谁在什么时候审核了哪张凭证,这个在财务系统里是刚需。这三件事都用最小成本换来可观测性。如果以后真想换框架,Servlet 这一层的 Service 尽量保持无状态接口,迁移成本会低很多。
6.2 用日志关键字守住日常维护:一套可复用的检查表
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 数据库连接数 | SHOW STATUS LIKE 'Threads_connected'; | 远低于 maxActive 配置 |
| 慢查询 | SHOW VARIABLES LIKE 'slow_query_log'; | 已开启,且 long_query_time <= 2 |
| 凭证号重复 | SELECT voucher_no, COUNT() FROM t_voucher GROUP BY voucher_no HAVING COUNT() > 1; | 无输出 |
| 未审核凭证 | SELECT COUNT(*) FROM t_voucher WHERE status = 0; | 月底前应接近 0 |
我现在接手这类系统,第一件事永远是确认连接池参数和慢查询日志是否打开,不然真出了账对不齐,查好几天都未必定位得到。另一个习惯是每个月末跑一次上面的检查表,顺手把数据备份做了,这些都是原生 Servlet 项目能不能稳定跑五年、十年的关键。希望帮到你。
本文还有配套的精品资源,点击获取