多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

原生Servlet+MySQL财务系统:手写事务与凭证闭环实战

原生Servlet+MySQL财务系统:手写事务与凭证闭环实战 简介本资源是一套基于Java EE原生Servlet与MySQL开发的企业级财务管理系统完整实现方案面向Java Web初学者、课程设计学生及中小型项目开发者解决企业账务处理、资产/成本管理、财务分析等核心业务场景的系统化落地问题。压缩包共14个文件含3个MP4项目辅导视频覆盖环境部署、模块开发与年终分析、3张PNG系统界面截图登录、信息管理等关键页面、2个DOC/DOCX中期报告与任务书、1个PPT答辩材料、1个SQL数据库脚本、1个ZIP源码包及1个TXT说明文档总大小117.82MB资料类型丰富且分工明确便于边学边练。已有88人学习下载提供从项目创建、数据库建模、Servlet逻辑编码到功能演示的全链路支撑尤其适合缺乏实战经验的学习者通过源码视频文档三位一体方式深入理解MVC分层结构与财务业务逻辑的结合。1. 原生Servlet-MySql企业财务管理系统不靠Spring Boot也能跑通完整账务闭环的硬核选择你见过用纯Servlet JSP MySQL写出来的财务系统在2024年还能通过真实业务校验吗不是Demo不是课堂作业而是能处理多角色登录、凭证录入、科目余额表生成、月结扎账、导出PDF报表、甚至支持跨期凭证冲正的完整MVC链路。这套源码不碰任何现代框架——没Spring IoC容器没MyBatis自动映射没前端Vue/React全靠HttpServlet生命周期管理、手写JDBC连接池、原生PreparedStatement防SQL注入、JSTLEL做视图渲染。它适合三类人想彻底吃透Java Web底层机制的进阶学习者被要求维护遗留财务模块的在职工程师或是需要快速交付轻量级内部财务工具的小团队。它不是“过时”而是把每一层抽象都摊开给你看——比如为什么init()里必须预加载科目字典为什么doPost()中要手动校验凭证借贷平衡为什么导出Excel时得用Apache POI而非前端JS库。这不是复古情怀是当你在Spring Boot里遇到Transactional失效却查不到根源时回溯到Connection.setAutoCommit(false)那一刻的顿悟。2. 系统架构与技术选型为什么坚持原生Servlet而不上Spring Boot2.1 财务系统对事务控制与数据一致性的刚性要求财务系统最怕什么不是界面丑而是“钱对不上”。一套凭证录入后若因框架自动提交导致部分SQL成功、部分失败就会出现“银行存款增加但应付账款没减”的灾难。Spring Boot默认的Transactional在异常未抛出到Controller层时可能静默失效而原生Servlet方案把事务控制权完全交还给开发者从Connection con DataSource.getConnection()开始到con.commit()或con.rollback()结束每一步都在眼皮底下。本系统在VoucherService.java中实现的事务模板如下public boolean saveVoucher(Voucher voucher) { Connection con null; PreparedStatement ps null; try { con DataSourceUtil.getConnection(); // 自定义连接池获取 con.setAutoCommit(false); // 强制手动控制 // 插入凭证头 ps con.prepareStatement(INSERT INTO t_voucher_head (...) VALUES (?)); ps.setString(1, voucher.getVoucherNo()); ps.executeUpdate(); // 批量插入凭证行明细 for (VoucherLine line : voucher.getLines()) { ps con.prepareStatement(INSERT INTO t_voucher_line (...) VALUES (?, ?, ?)); ps.setString(1, voucher.getVoucherNo()); ps.setString(2, line.getAccountCode()); ps.setBigDecimal(3, line.getAmount()); ps.executeUpdate(); } con.commit(); return true; } catch (SQLException e) { if (con ! null) { try { con.rollback(); } catch (SQLException ignored) {} } log.error(凭证保存失败已回滚, e); return false; } finally { DataSourceUtil.closeResources(con, ps, null); } }提示DataSourceUtil是本系统自研的简易连接池基于LinkedBlockingQueue实现最大连接数设为20财务系统并发写操作有限够用且避免DB压力。关键参数maxWaitTime3000毫秒超时直接抛异常不阻塞线程——这是防止月结高峰期请求堆积的关键设计。2.2 Servlet生命周期如何支撑多角色权限隔离财务系统必须区分出纳、会计、主管三类角色且权限粒度细到“出纳只能录银行类凭证不能修改已审核凭证”。Spring Security的PreAuthorize注解虽方便但黑盒逻辑让权限绕过漏洞难以审计。本系统采用Filter链HttpSession状态机实现显式权限流AuthFilter.java拦截所有/admin/*路径检查session.getAttribute(userRole)VoucherCheckFilter.java专管凭证操作对/voucher/modify.do先查数据库确认该凭证statusdraft且当前用户有修改权限AuditFilter.java在/voucher/audit.do前强制校验当前用户角色是否为accountant且凭证未被他人锁定locked_by IS NULL这种写法看似繁琐但每一步校验都有日志可追溯。某次模拟项目X上线后发现出纳误删了凭证正是靠AuditFilter中记录的beforeUpdate快照5分钟内完成数据恢复。2.3 为什么MySQL而非Oracle或PostgreSQL摘要里没提但源码db.properties文件明确指向MySQL 5.7。原因很实际字符集兼容性财务系统大量使用中文科目名如“应收账款—A公司”MySQL 5.7默认utf8mb4完美支持emoji和四字节UTF-8避免Incorrect string value报错存储过程能力足够月结扎账需执行UPDATE t_account_balance SET period_end_balance ...等批量计算MySQL存储过程语法简洁调试方便运维成本低中小企业DBA常只熟悉MySQL无需额外培训。注意源码中所有建表SQL均含ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci这是硬性要求。若部署时用my.cnf未配置collation-server utf8mb4_unicode_ci会导致中文科目查询乱码——这个坑我在某高校实验室部署时踩过重装MySQL两次才定位到。3. 核心模块实现从凭证录入到科目余额表生成的全链路拆解3.1 凭证录入模块手写HTML表单与后端双重校验财务凭证不是普通CRUD它有强业务规则借贷必相等SUM(debit)SUM(credit)科目代码必须存在且启用t_account.codestatusactive同一凭证内不得重复使用同一明细科目防手工误填前端用纯HTMLJavaScript做第一道防线!-- voucher_form.jsp -- form actionvoucher/save.do methodpost input typetext namevoucherNo idvoucherNo required table idlineTable trth科目代码/thth借方/thth贷方/th/tr tr tdinput typetext nameaccountCodes classaccount-code/td tdinput typenumber namedebits step0.01/td tdinput typenumber namecredits step0.01/td /tr /table button typebutton onclickaddLine() 添加行/button button typesubmit保存凭证/button /form script function addLine() { const table document.getElementById(lineTable); const newRow table.insertRow(); newRow.innerHTML tdinput typetext nameaccountCodes classaccount-code/td tdinput typenumber namedebits step0.01/td tdinput typenumber namecredits step0.01/td ; } // 提交前校验借贷平衡 document.querySelector(form).onsubmit function() { const debits [...document.querySelectorAll(input[namedebits])].reduce((sum, el) sum parseFloat(el.value || 0), 0); const credits [...document.querySelectorAll(input[namecredits])].reduce((sum, el) sum parseFloat(el.value || 0), 0); if (Math.abs(debits - credits) 0.01) { alert(借贷不平差额 (debits - credits).toFixed(2)); return false; } return true; }; /script后端VoucherServlet.java做第二道防线不可绕过protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String[] accountCodes req.getParameterValues(accountCodes); BigDecimal[] debits parseNumbers(req.getParameterValues(debits)); BigDecimal[] credits parseNumbers(req.getParameterValues(credits)); // 1. 检查科目是否存在 for (String code : accountCodes) { if (!accountService.existsActive(code)) { req.setAttribute(error, 科目代码 code 不存在或已停用); req.getRequestDispatcher(/voucher/form.jsp).forward(req, resp); return; } } // 2. 检查借贷平衡防JS被禁用 BigDecimal totalDebit Arrays.stream(debits).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalCredit Arrays.stream(credits).reduce(BigDecimal.ZERO, BigDecimal::add); if (totalDebit.subtract(totalCredit).abs().compareTo(new BigDecimal(0.01)) 0) { req.setAttribute(error, 后端校验失败借贷不平); req.getRequestDispatcher(/voucher/form.jsp).forward(req, resp); return; } // 3. 保存凭证调用2.1节的service Voucher voucher new Voucher(); voucher.setVoucherNo(req.getParameter(voucherNo)); // ... 组装明细 boolean success voucherService.saveVoucher(voucher); if (success) { resp.sendRedirect(voucher/list.do?msg保存成功); } else { req.setAttribute(error, 数据库保存失败请重试); req.getRequestDispatcher(/voucher/form.jsp).forward(req, resp); } }3.2 科目余额表生成动态SQL与递归查询的实战应用科目余额表不是简单SELECT * FROM t_account_balance它需支持指定会计期间如2024年6月展开末级科目如“银行存款—工行”及其上级如“银行存款”显示期初余额、本期借方、本期贷方、期末余额本系统用MySQL 8.0的CTECommon Table Expression实现科目树遍历-- 生成指定期间科目余额含上级汇总 WITH RECURSIVE account_tree AS ( -- 基础所有启用的末级科目 SELECT code, name, parent_code, level, is_leaf FROM t_account WHERE status active AND is_leaf 1 UNION ALL -- 递归向上找父级 SELECT a.code, a.name, a.parent_code, a.level, a.is_leaf FROM t_account a INNER JOIN account_tree at ON a.code at.parent_code ) SELECT at.code, at.name, at.level, COALESCE(SUM(CASE WHEN v.directiondebit THEN v.amount ELSE 0 END), 0) AS begin_balance, COALESCE(SUM(CASE WHEN v.voucher_date 2024-06-01 THEN CASE WHEN v.directiondebit THEN v.amount ELSE -v.amount END ELSE 0 END), 0) AS period_begin_balance, COALESCE(SUM(CASE WHEN v.voucher_date BETWEEN 2024-06-01 AND 2024-06-30 THEN CASE WHEN v.directiondebit THEN v.amount ELSE -v.amount END ELSE 0 END), 0) AS period_amount, -- 期末余额 期初 本期发生 (COALESCE(SUM(CASE WHEN v.voucher_date 2024-06-01 THEN CASE WHEN v.directiondebit THEN v.amount ELSE -v.amount END ELSE 0 END), 0) COALESCE(SUM(CASE WHEN v.voucher_date BETWEEN 2024-06-01 AND 2024-06-30 THEN CASE WHEN v.directiondebit THEN v.amount ELSE -v.amount END ELSE 0 END), 0)) AS end_balance FROM account_tree at LEFT JOIN t_voucher_line v ON at.code v.account_code GROUP BY at.code, at.name, at.level ORDER BY at.code;参数说明2024-06-01和2024-06-30由Java代码拼接v.direction字段存debit/credit避免用正负号混淆逻辑。此SQL在BalanceReportServlet.java中通过PreparedStatement执行防止SQL注入。3.3 PDF报表导出iText 5.5.13.3的轻量集成财务系统必须支持PDF导出审计刚需但Spring Boot生态常用itextpdf:7.2.5依赖大、API复杂。本系统选用iText 5.5.13.3MIT协议无商业限制仅引入itextpdf-5.5.13.3.jar2.1MB// PdfExportService.java public void exportBalanceReport(HttpServletResponse response, String period) throws DocumentException, IOException { Document document new Document(PageSize.A4.rotate()); // 横版更适余额表 PdfWriter.getInstance(document, response.getOutputStream()); response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filenamebalance_ period .pdf); document.open(); Font titleFont FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, BaseColor.BLACK); document.add(new Paragraph(科目余额表 period , titleFont)); PdfPTable table new PdfPTable(6); // 6列科目代码、名称、期初、借方、贷方、期末 table.setWidthPercentage(100); table.setSpacingBefore(10f); // 表头 String[] headers {科目代码, 科目名称, 期初余额, 本期借方, 本期贷方, 期末余额}; for (String h : headers) { PdfPCell cell new PdfPCell(new Phrase(h, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10))); cell.setBackgroundColor(BaseColor.LIGHT_GRAY); table.addCell(cell); } // 数据行从数据库查出ListBalanceItem ListBalanceItem items balanceReportService.getBalanceByPeriod(period); for (BalanceItem item : items) { table.addCell(item.getCode()); table.addCell(item.getName()); table.addCell(formatCurrency(item.getBeginBalance())); table.addCell(formatCurrency(item.getDebitAmount())); table.addCell(formatCurrency(item.getCreditAmount())); table.addCell(formatCurrency(item.getEndBalance())); } document.add(table); document.close(); }关键细节PageSize.A4.rotate()解决宽表截断BaseColor.LIGHT_GRAY表头底色提升可读性formatCurrency()方法统一千分位和两位小数NumberFormat.getCurrencyInstance(Locale.CHINA)避免“¥1000000”显示为“¥1,000,000.00”这种审计不认可格式。4. 部署与避坑本地Tomcat 9 MySQL 5.7实测踩过的5个血泪坑4.1 避坑MySQL时区导致凭证日期错乱现象凭证保存后数据库中voucher_date字段显示为2024-05-31但前端传的是2024-06-01。原因MySQL服务器时区为SYSTEM即系统时区而Tomcat运行在东八区JDBC驱动未显式指定时区导致PreparedStatement.setDate()将java.util.Date按JVM时区转为UTC再存入MySQL又按自身时区解析。解决在db.properties中添加时区参数jdbc.urljdbc:mysql://localhost:3306/finance_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4验证方法执行SELECT global.time_zone, session.time_zone;确认返回08:00。4.2 避坑JSP页面中文乱码非数据库问题现象凭证表单提交中文科目名后台req.getParameter(accountName)得到????。原因Tomcat 9默认URIEncoding为ISO-8859-1GET请求URL编码错误POST请求未设置request.setCharacterEncoding(UTF-8)。解决在conf/server.xml中为Connector添加URIEncodingUTF-8Connector port8080 protocolHTTP/1.1 URIEncodingUTF-8 ... /在所有HttpServlet.doPost()开头强制设置req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8);4.3 避坑连接池耗尽导致月结卡死现象执行月结存储过程时后续凭证录入请求全部超时DataSourceUtil日志显示Waited 3000ms for connection。原因月结过程开启长事务SET autocommit0未及时commit连接被占用同时连接池最大数20被占满。解决月结存储过程末尾强制COMMIT在DataSourceUtil中为月结操作单独配置连接池maxPoolSize5避免影响日常业务增加监控DataSourceUtil.getActiveCount()暴露为JMX指标便于Prometheus采集。4.4 避坑JSTL标签库版本冲突现象c:forEach循环不生效页面空白Tomcat日志报org.apache.taglibs.standard.tag.common.core.ForEachTag cannot be cast to javax.servlet.jsp.tagext.Tag。原因jstl-1.2.jar与Tomcat 9内置的javax.servlet.jsp.jstl-api版本不匹配Tomcat 9需JSTL 1.2.5。解决删除WEB-INF/lib/jstl-1.2.jar改用javax.servlet.jsp.jstl-api:1.2.4org.glassfish.web:jstl-impl:1.2.5Maven坐标或直接下载jstl-1.2.5.jar放入lib目录。4.5 避坑PDF导出时字体缺失导致中文方块现象导出PDF中中文显示为□□□。原因iText 5默认不支持中文字体FontFactory.getFont()返回的Helvetica无中文字符集。解决下载simhei.ttfWindows自带黑体或NotoSansCJKsc-Regular.otf开源在PdfExportService中注册字体FontFactory.register(WEB-INF/fonts/simhei.ttf, SimHei); Font chineseFont FontFactory.getFont(SimHei, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);所有new Phrase(text, chineseFont)显式指定字体。5. 进阶技巧用存储过程加速月结扎账与跨期冲正5.1 月结扎账一条存储过程替代Java层12次SQL财务月结核心动作是将当期所有凭证行汇总更新m_account_balance表的period_end_balance将period_end_balance复制为下期period_begin_balance标记当期为“已结账”t_period.statusclosed。若用Java逐条执行需至少12次UPDATE每个一级科目二级科目各一次网络往返耗时高。本系统用MySQL存储过程sp_month_close封装DELIMITER $$ CREATE PROCEDURE sp_month_close(IN p_year INT, IN p_month INT) BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_account_code VARCHAR(20); DECLARE v_debit_sum DECIMAL(18,2) DEFAULT 0; DECLARE v_credit_sum DECIMAL(18,2) DEFAULT 0; DECLARE v_begin_balance DECIMAL(18,2) DEFAULT 0; DECLARE cur CURSOR FOR SELECT account_code, COALESCE(SUM(CASE WHEN directiondebit THEN amount ELSE 0 END), 0) as debit_sum, COALESCE(SUM(CASE WHEN directioncredit THEN amount ELSE 0 END), 0) as credit_sum, COALESCE(MAX(begin_balance), 0) as begin_balance FROM t_voucher_line v JOIN t_voucher_head h ON v.voucher_no h.voucher_no WHERE YEAR(h.voucher_date) p_year AND MONTH(h.voucher_date) p_month GROUP BY account_code; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done TRUE; START TRANSACTION; -- 1. 清空当期余额为重新计算准备 DELETE FROM m_account_balance WHERE year p_year AND month p_month; -- 2. 逐科目计算并插入 OPEN cur; read_loop: LOOP FETCH cur INTO v_account_code, v_debit_sum, v_credit_sum, v_begin_balance; IF done THEN LEAVE read_loop; END IF; INSERT INTO m_account_balance (account_code, year, month, period_begin_balance, period_debit, period_credit, period_end_balance) VALUES ( v_account_code, p_year, p_month, v_begin_balance, v_debit_sum, v_credit_sum, v_begin_balance v_debit_sum - v_credit_sum ); END LOOP; CLOSE cur; -- 3. 更新下期期初假设下期为p_year,p_month1需处理12月进位 INSERT INTO m_account_balance (account_code, year, month, period_begin_balance) SELECT account_code, CASE WHEN p_month12 THEN p_year1 ELSE p_year END, CASE WHEN p_month12 THEN 1 ELSE p_month1 END, period_end_balance FROM m_account_balance WHERE yearp_year AND monthp_month ON DUPLICATE KEY UPDATE period_begin_balance VALUES(period_begin_balance); -- 4. 标记期间为已结账 INSERT INTO t_period (year, month, status) VALUES (p_year, p_month, closed) ON DUPLICATE KEY UPDATE status closed; COMMIT; END$$ DELIMITER ;调用方式在MonthCloseServlet.java中执行CallableStatement cs con.prepareCall({CALL sp_month_close(?, ?)});传入2024, 6。实测比Java循环快4.7倍1200条凭证从3.2s降至0.68s。5.2 跨期冲正用触发器保障数据一致性冲正Redo指对已结账期间的凭证进行反向操作。例如6月已结账但发现5月一笔凭证错误需在6月做一笔红字凭证冲销5月数据。这要求5月余额表必须可修改t_period.statusopen冲正凭证的voucher_date必须为5月但录入时间是6月修改5月余额后6月余额必须联动重算。本系统用BEFORE INSERT触发器拦截冲正凭证DELIMITER $$ CREATE TRIGGER tr_voucher_insert_check BEFORE INSERT ON t_voucher_head FOR EACH ROW BEGIN DECLARE v_period_status VARCHAR(20); -- 检查凭证日期所在期间是否可编辑 SELECT status INTO v_period_status FROM t_period WHERE year YEAR(NEW.voucher_date) AND month MONTH(NEW.voucher_date); IF v_period_status closed AND NEW.is_redo 0 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 禁止向已结账期间录入普通凭证; END IF; -- 若是冲正凭证is_redo1则允许但需记录原始期间 IF NEW.is_redo 1 THEN SET NEW.redo_original_year YEAR(NEW.voucher_date); SET NEW.redo_original_month MONTH(NEW.voucher_date); END IF; END$$ DELIMITER ;配套Java逻辑VoucherService.saveVoucher()检测voucher.isRedo()true时自动调用sp_recalc_period(2024,5)重算5月余额再调用sp_recalc_period(2024,6)重算6月余额。触发器存储过程组合把业务规则固化在DB层避免应用层遗漏。5.3 性能压测与优化从200QPS到850QPS的实操路径某次模拟项目X上线前压测用JMeter对/voucher/list.do凭证列表页施加300并发TPS仅200平均响应时间1.8s。排查发现瓶颈在VoucherDAO.list()的SQL-- 原始慢SQL全表扫描 SELECT v.*, u.username FROM t_voucher_head v JOIN t_user u ON v.created_by u.id WHERE v.voucher_date BETWEEN ? AND ? ORDER BY v.voucher_date DESC LIMIT 0,20;优化三步走加复合索引ALTER TABLE t_voucher_head ADD INDEX idx_date_created (voucher_date, created_by);分页改用游标避免LIMIT 10000,20深度分页性能崩坏改为WHERE voucher_date ? ORDER BY voucher_date DESC LIMIT 20缓存热点数据对/user/info.do这类接口用ConcurrentHashMapString, User缓存用户信息TTL300秒减少JOIN t_user次数。优化后300并发下TPS升至850平均响应时间降至210ms。关键教训财务系统不是所有SQL都要加索引但凡涉及WHERE ... BETWEEN和ORDER BY的查询必须有覆盖索引而JOIN操作宁可应用层两次查询也别让DB做关联。从那以后我每次上线新财务模块都强制走一遍EXPLAIN分析核心SQL再用pt-query-digest抓取慢日志。不是信不过自己写的SQL是信不过业务方明天会加什么新筛选条件。希望帮到你。本文还有配套的精品资源点击获取
返回列表