恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
原生Servlet+MySQL企业财务管理系统:架构、事务与部署实践
首页
资讯中心
/
原生Servlet+MySQL企业财务管理系统:架构、事务与部署实践
原生Servlet+MySQL企业财务管理系统:架构、事务与部署实践
发布时间:2026/10/20 22:37:36
简介一套基于原生Servlet和MySQL数据库的企业财务管理系统设计与实现项目资料面向Java Web学习者、毕业设计开发者以及需要参考完整业务场景的初中级工程师系统遵循模块化设计覆盖资产管理、负债管理、成本管理、财务分析等核心功能完整展示了从数据库表设计、Servlet请求处理到JSP页面渲染的企业级开发路径。资源压缩包共14个文件、总体积117.82MB内部包含可导入运行的SQL数据库脚本、项目源码压缩包、多份Word/PPT文档、三张界面截图以及三段操作演示视频其中文档资料涵盖任务书、中期报告和最终毕业论文视频演示了项目创建、部署、数据库搭建及主要功能操作流程整体结构清晰方便按需查阅。当前已有88人学习浏览借助完整源码、数据库脚本和配套视频文档读者可以快速部署运行系统深入理解财务模块的实现细节也能为毕业设计或课程设计提供可直接扩展的代码基础和答辩参考素材。1. 原生Servlet-MySQL企业财务管理系统老技术栈为什么还在被选择很多人看到“原生Servlet”四个字第一反应是这技术早该进博物馆了但在实际交付里原生Servlet-MySQL企业财务管理系统仍然是中小型企业内部记账、凭证管理、科目余额查询和基础报表统计的常见选型。它不依赖任何重量级框架请求从容器直接路由到ServletSQL和业务逻辑都能一眼看穿出了问题不需要在框架的黑匣子里翻来翻去。标题里的“源码数据库”说明这是一套完整可运行的交付物拿到手改一改就能跑既能当课程设计和毕业设计的技术底座也能撑起小型团队的真实业务。这套系统能解决什么问题对于没有专职运维、预算有限的小公司来说它管住了最核心的财务数据一致性凭证借贷必须平衡、明细必须挂到有效科目、操作必须留痕。它适合三类人正在做课程设计或毕业设计、需要把请求处理讲明白的学生需要快速搭内部管理系统的开发者以及那些被Spring全家桶绕晕了、想看看一次HTTP请求到底怎么被代码接住的从业者。财务场景有个特点宁可功能少不能数据错。Servlet这种朴素架构恰恰把数据流的每一步都摊在明面上没有自动注入、没有隐式约定代码即文档。这也是为什么这么多年过去这个组合还在被选择。2. ServletMySQL架构拆解三层职责、核心表与连接池选型2.1 三层架构在财务系统里的落地Servlet、Service、DAO怎么分原生Servlet项目虽然没有框架的容器管理但分层思想必须严格执行甚至比框架项目更严格。财务系统的业务规则是堆在数据一致性上的一张凭证借方总额必须等于贷方总额这个校验如果散落在每个Servlet里后面每加一个功能就复制一遍早晚会漏掉某个入口。落在Service层所有入口共用一套校验这才是财务系统的底线。三层职责划分是这样的表现层的JSP管渲染Servlet接请求、收参数、调服务、决定跳转业务层的Service类管业务规则和事务边界数据访问层的DAO类只做JDBC读写一个DAO对应一张核心表。分层带来的直接好处是改动可控会计科目编码规则变了你只需要动科目相关的Service和DAO而不是在多个Servlet里搜索同一段SQL。我在实际做过的模拟项目X里最开始图省事把SQL直接写在Servlet里三个月后被迫重构一半时间花在找“这段SQL在哪几个Servlet里各有一份”上血泪教训。常见的代码结构长这样src ├── controller # Servlet处理请求与跳转 │ ├── LoginServlet.java │ ├── VoucherServlet.java │ └── ReportServlet.java ├── service # 业务逻辑与事务边界 │ ├── VoucherService.java │ └── ReportService.java ├── dao # JDBC数据访问 │ ├── UserDao.java │ ├── VoucherDao.java │ └── AccountSubjectDao.java ├── entity # 实体类 │ ├── User.java │ └── Voucher.java └── util └── DBUtil.javaServlet只做三件事取参数、调Service、回JSP或JSON。凡是出现Servlet里直接拼SQL、直接处理事务的都属于边界失控。一个反例是某同事把八百行逻辑全塞进一个Servlet功能看着齐全但改任何一个字段都要在里面翻半天这种代码在财务系统里是灾难——因为字段是跟着会计准则走的改动频率远高于普通管理系统。2.2 核心数据表科目、凭证、明细账的字段设计与索引数据库设计决定业务能不能跑通。一个最小可用的财务管理系统至少需要四张核心表我一般还会建议加一张期间余额表用于月末结转和余额查询表名用途关键字段sys_user系统用户与角色username、password、roleaccount_subject会计科目subject_code、subject_name、category、balance_directionvoucher记账凭证主表voucher_no、voucher_date、debit_total、credit_total、statusvoucher_detail凭证明细voucher_id、subject_code、debit_amount、credit_amountsubject_balance科目期间余额subject_code、period、opening_balance、closing_balance先说金额字段。所有涉及金额的列一律用DECIMAL(14,2)这是财务系统的铁律。用double存金额1.1 2.2会得到3.3000000000000003账面不平的时候你根本分不清是真错了还是浮点精度丢了。DECIMAL(14,2)表示最多 12 位整数加 2 位小数对绝大多数企业内部账目绰绰有余。科目表为什么要单独建因为科目有层级和方向。1001 库存现金是借方余额2001 短期借款是贷方余额记账时凭证必须按科目的余额方向填写。这个方向如果靠代码里硬编码科目一多就无法维护所以balance_direction字段D借/C贷是科目表的核心。我还见过把科目表砍掉、直接在凭证里写VARCHAR科目名的设计查询汇总时连“管理费用一共多少”都统计不出来这是典型的反面教材。索引方面凭证表按凭证号和时间查询最频繁建(voucher_date, status)联合索引明细表按凭证ID关联查询voucher_id必须建索引外键约束会自动建隐式索引但显式写出来更利于理解。我见过一个翻车现场某公司内部系统凭证积累到两万张后明细账查询从 300 毫秒退化到 8 秒原因就是voucher_detail表的voucher_id没建索引每次都全表扫描。加上索引后直接回到 200 毫秒以内。2.3 数据库连接从DriverManager裸连到连接池改造原生Servlet项目里数据库连接有两种做法。裸连代码最简单适合学习和低并发场景一个工具类搞定public class DBUtil { // URL里必须带 characterEncoding 和 serverTimezone缺一个都容易出乱码或时区报错 private static final String URL jdbc:mysql://localhost:3306/finance_db ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(找不到MySQL驱动检查WEB-INF/lib下的jar包); } } 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 ignore) {} } if (st ! null) { try { st.close(); } catch (SQLException ignore) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignore) {} } } }这段代码的几个参数是踩坑高发区characterEncodingutf8保证中文不乱码serverTimezoneAsia/Shanghai解决新版驱动时区报错MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver老项目里如果写的是com.mysql.jdbc.Driver在 MySQL 8 下直接 ClassNotFound。裸连的问题在于每次请求都重新建TCP连接、认证、断开高并发下连接数会快速打满数据库。生产环境要换连接池。连接池的核心参数就那么几个参数建议值说明初始连接数5启动时预建的连接量最大连接数20超过后新请求排队等待空闲超时60秒空闲连接超过该时间被回收获取连接超时5秒拿不到连接时抛出异常的上限把DBUtil.getConnection()从裸连换成从池子取连接只动工具类这一个类Service和DAO完全不用改——这是分层带来的第二个好处。要注意的是用完的conn.close()在池子场景下不是真关闭而是归还连接所以DAO里的 try-with-resources 写法依然成立。注意连接池的“最大连接数”不是越大越好财务管理系统并发本来就不高20 足够设成 200 反而会把数据库连接数打爆报 “Too many connections” 时先从池子参数查起。3. 把核心模块写成代码登录鉴权、凭证录入与分页查询3.1 登录与Session管理会话超时与角色控制的实现细节财务系统的登录和普通网站登录有个明显区别必须区分角色。会计录凭证、出纳管现金、审核员审凭证、管理员建用户。角色权限如果只靠页面隐藏按钮那纯属自欺欺人——直接拼URL就能绕过。正确做法是登录成功后把用户和角色写进Session然后在Servlet入口统一校验。登录Servlet的常见写法WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 先统一编码再做参数校验顺序不能反 req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); // 参数为空的场景必须在Servlet层拦一道 if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { req.setAttribute(error, 用户名和密码不能为空); req.getRequestDispatcher(login.jsp).forward(req, resp); return; } User user new UserDao().findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(login.jsp).forward(req, resp); return; } HttpSession session req.getSession(true); // 登录成功后重新创建session防止会话固定攻击 session.invalidate(); session req.getSession(true); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作自动失效 resp.sendRedirect(req.getContextPath() /index.jsp); } }这里有两个容易忽略的细节。第一密码校验不要拿数据库明文直接equals生产做法是存加盐哈希登录时把输入密码哈希后再比对。很多教学项目直接存明文如果这个系统要真给公司用这是第一个要改的地方。第二setMaxInactiveInterval单位是秒30分钟是财务系统比较常见的会话超时时间——太短会计填一张凭证的功夫就被踢出登录页太长财务数据在无人值守电脑上暴露过久。另一个细节是session.invalidate()再重新创建用来防会话固定攻击。攻击者先拿到一个会话ID诱导用户登录登录成功后如果不换会话ID攻击者就能冒用身份。这个写法在框架项目里是标配原生Servlet里容易被漏掉。3.2 凭证录入Servlet参数校验、事务边界与批处理凭证录入是整个系统最核心的写操作。一张凭证包含一条主表记录和至少两条明细记录一借一贷必须同时成功或同时失败这就是典型的事务场景。如果主表插进去了、明细插入失败账面上就会出现一张“只有借贷总额没有明细”的脏凭证。Service层的保存方法示例public boolean saveVoucher(Voucher voucher, ListVoucherDetail details) { Connection conn null; PreparedStatement psVoucher null; PreparedStatement psDetail null; try { // 1. 业务校验借贷必须平衡这是财务铁律 BigDecimal debitTotal BigDecimal.ZERO; BigDecimal creditTotal BigDecimal.ZERO; for (VoucherDetail d : details) { debitTotal debitTotal.add(d.getDebitAmount()); creditTotal creditTotal.add(d.getCreditAmount()); } if (debitTotal.compareTo(creditTotal) ! 0) { throw new BusinessException(凭证借贷不平衡借方 debitTotal 贷方 creditTotal); } voucher.setDebitTotal(debitTotal); voucher.setCreditTotal(creditTotal); conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交事务开始 // 2. 插入凭证主表并声明要返回自增主键 psVoucher conn.prepareStatement( INSERT INTO voucher(voucher_no, voucher_date, summary, debit_total, credit_total, status) VALUES(?, ?, ?, ?, ?, ?), Statement.RETURN_GENERATED_KEYS); psVoucher.setString(1, voucher.getVoucherNo()); psVoucher.setDate(2, new java.sql.Date(voucher.getVoucherDate().getTime())); psVoucher.setString(3, voucher.getSummary()); psVoucher.setBigDecimal(4, debitTotal); psVoucher.setBigDecimal(5, creditTotal); psVoucher.setString(6, 已审核); psVoucher.executeUpdate(); // 3. 取出自增主键供明细表做外键关联 long voucherId; try (ResultSet rs psVoucher.getGeneratedKeys()) { rs.next(); voucherId rs.getLong(1); } // 4. 明细表用批处理避免逐条executeUpdate的多次网络往返 psDetail conn.prepareStatement( INSERT INTO voucher_detail(voucher_id, subject_code, summary, debit_amount, credit_amount) VALUES(?, ?, ?, ?, ?)); for (VoucherDetail d : details) { psDetail.setLong(1, voucherId); psDetail.setString(2, d.getSubjectCode()); psDetail.setString(3, d.getSummary()); psDetail.setBigDecimal(4, d.getDebitAmount()); psDetail.setBigDecimal(5, d.getCreditAmount()); psDetail.addBatch(); } psDetail.executeBatch(); conn.commit(); // 全部成功才提交 return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException(凭证保存失败, e); } finally { DBUtil.close(conn, psDetail, null); DBUtil.close(null, psVoucher, null); } }这段代码的关键点。第一setAutoCommit(false)必须在同一个Connection对象上执行如果主表和明细分别调DBUtil.getConnection()拿不同的连接事务就变成两个独立事务commit和rollback都管不到对方——这是事务最常见的翻车姿势。第二BigDecimal比较不能用equals1.0和1.00的 scale 不同equals返回 false必须用compareTo。第三executeBatch()一次提交一批明细比循环executeUpdate()快很多凭证明细动辄十几条时差异明显。第四用getGeneratedKeys()拿自增主键不要用SELECT MAX(id)再查一次并发插入时会拿到别人的主键。凭证保存前还要校验科目是否存在、科目余额方向和分录方向是否匹配比如贷方科目如果是个借方余额科目大概率是录错了。这些校验放在同一个方法里有异常就 rollback保证脏数据进不了库。3.3 明细账查询JOIN聚合与分页在原生Servlet里的写法查询场景比写入更频繁按期间查凭证、按科目查明细账、按摘要模糊搜索。原生Servlet没有现成分页组件但分页本身不复杂核心是 SQL 的LIMIT ?, ?加总数统计。public ListVoucher findVouchersByPage(int page, int pageSize, String startDate, String endDate) { // 半开区间写法开始日期当天00:00:00结束日期传下个月的1号 // 这样能完整覆盖结束日期当天的全部凭证且不依赖 DATE() 函数 String sql SELECT id, voucher_no, voucher_date, summary, debit_total, credit_total, status FROM voucher WHERE voucher_date ? AND voucher_date ? ORDER BY voucher_date DESC, id DESC LIMIT ?, ?; int offset (page - 1) * pageSize; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, startDate); // 例如 2024-06-01 ps.setString(2, endDate); // 例如 2024-07-01即含6月1日至6月30日全天 ps.setInt(3, offset); ps.setInt(4, pageSize); try (ResultSet rs ps.executeQuery()) { ListVoucher list new ArrayList(); while (rs.next()) { Voucher v new Voucher(); v.setId(rs.getLong(id)); v.setVoucherNo(rs.getString(voucher_no)); v.setVoucherDate(rs.getDate(voucher_date)); v.setSummary(rs.getString(summary)); v.setDebitTotal(rs.getBigDecimal(debit_total)); v.setCreditTotal(rs.getBigDecimal(credit_total)); list.add(v); } return list; } } catch (SQLException e) { throw new RuntimeException(凭证分页查询失败, e); } }配套的总数查询是SELECT COUNT(*) FROM voucher WHERE voucher_date ? AND voucher_date ?页面总页数用(total pageSize - 1) / pageSize计算。分页参数有个常见错误page从 1 开始offset (page - 1) * pageSize直接把page当 offset 会导致第一页跳过前几条数据。日期条件用了voucher_date ? AND voucher_date ?的半开区间而不是BETWEEN ? AND ?。原因是如果哪天字段从DATE改成DATETIMEBETWEEN 2024-06-01 AND 2024-06-30会漏掉 6 月 30 日 00:00:00 之后的全部记录。半开区间没有这个边界问题。科目汇总查询则用GROUP BY subject_code聚合当月发生额按科目维度生成发生额及余额表这也是后面做报表的基础。4. 让系统第一次启动就能跑通建库脚本、初始化数据与部署配置4.1 建库建表脚本字符集、金额类型与索引的落地细节拿到一个“源码数据库”的财务系统项目第一步永远先看数据库脚本而不是先跑代码。脚本里藏着系统真正的设计意图用什么字符集、金额字段用什么类型、外键怎么建、索引落在哪些列。一份合格的建库脚本至少包含下面这些CREATE DATABASE IF NOT EXISTS finance_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE finance_db; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户内部ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码哈希值不要存明文, real_name VARCHAR(50) DEFAULT NULL COMMENT 真实姓名, role VARCHAR(20) NOT NULL DEFAULT accountant COMMENT 角色: admin/accountant/auditor, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_role (role) ) ENGINEInnoDB COMMENT系统用户表; CREATE TABLE account_subject ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_code VARCHAR(20) NOT NULL UNIQUE COMMENT 科目编码如1001, subject_name VARCHAR(100) NOT NULL COMMENT 科目名称如库存现金, category VARCHAR(10) NOT NULL COMMENT 类别: 资产/负债/权益/成本/损益, balance_direction CHAR(1) NOT NULL COMMENT 余额方向: D借 / C贷, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB COMMENT会计科目表; CREATE TABLE voucher ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(30) NOT NULL COMMENT 凭证字号如记-2024-06-001, voucher_date DATE NOT NULL COMMENT 记账日期, summary VARCHAR(200) DEFAULT NULL COMMENT 摘要, debit_total DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT 借方合计, credit_total DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT 贷方合计, status VARCHAR(10) NOT NULL DEFAULT 草稿 COMMENT 草稿/已审核/已记账, create_by BIGINT DEFAULT NULL COMMENT 录入人ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_date_status (voucher_date, status), KEY idx_voucher_no (voucher_no) ) ENGINEInnoDB COMMENT记账凭证主表; CREATE TABLE voucher_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_id BIGINT NOT NULL COMMENT 所属凭证ID, subject_code VARCHAR(20) NOT NULL COMMENT 会计科目编码, summary VARCHAR(200) DEFAULT NULL COMMENT 分录摘要, debit_amount DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT 借方金额, credit_amount DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT 贷方金额, KEY idx_voucher_id (voucher_id), KEY idx_subject_code (subject_code), CONSTRAINT fk_detail_voucher FOREIGN KEY (voucher_id) REFERENCES voucher(id) ) ENGINEInnoDB COMMENT凭证明细表;脚本里的落地细节整库用utf8mb4而不是utf8因为MySQL的utf8最多存3字节遇到生僻字会写入失败凭证摘要里出现特殊字符时只有utf8mb4能存下。金额统一DECIMAL(14,2)不允许出现FLOAT或DOUBLE。凭证表建(voucher_date, status)联合索引覆盖“某时间段内某状态凭证”这个最高频查询明细表的voucher_id显式建索引。所有表用InnoDB支持事务和外键财务场景缺一不可MyISAM不支持行级锁和事务用它做财务表是硬伤。注意如果脚本里出现SET FOREIGN_KEY_CHECKS 0;通常是导入时为了不受外键顺序限制。导入完成后一定恢复为 1否则后续插入不受外键约束保护删除父表记录时子表会留下悬空引用。4.2 初始化数据管理员账号、会计科目与演示凭证建完表后系统还是空的必须初始化一批数据才能登录和演示。初始化数据分三类用户、科目、演示凭证。-- 管理员账号admin / admin123这里存的是MD5哈希实际项目应加盐 INSERT INTO sys_user(username, password, real_name, role) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 系统管理员, admin), (acc001, e10adc3949ba59abbe56e057f20f883e, 张会计, accountant); -- 一级会计科目资产、负债、权益、成本、损益 INSERT INTO account_subject(subject_code, subject_name, category, balance_direction) VALUES (1001, 库存现金, 资产, D), (1002, 银行存款, 资产, D), (1122, 应收账款, 资产, D), (2001, 短期借款, 负债, C), (2202, 应付账款, 负债, C), (4001, 实收资本, 权益, C), (5001, 生产成本, 成本, D), (6001, 主营业务收入, 损益, C), (6601, 销售费用, 损益, D), (6602, 管理费用, 损益, D); -- 一张演示凭证收到股东投资款借贷必须平衡 INSERT INTO voucher(voucher_no, voucher_date, summary, debit_total, credit_total, status, create_by) VALUES (记-2024-06-001, 2024-06-01, 收到投资款, 500000.00, 500000.00, 已审核, 1); INSERT INTO voucher_detail(voucher_id, subject_code, summary, debit_amount, credit_amount) VALUES (1, 1002, 收到投资款存入银行, 500000.00, 0), (1, 4001, 收到投资款, 0, 500000.00);初始化数据的坑在于密码哈希必须和登录校验逻辑匹配。很多项目给的脚本存的是明文admin123而登录代码里做了MD5加密两边一比对永远登录不进去。拿到别人的项目先确认登录校验是明文比对还是哈希比对再决定初始化数据里放什么。演示凭证的金额要保持借贷平衡否则第一个演示操作就报“借贷不平衡”第一印象直接垮掉。4.3 导入数据库与部署从命令行导入到容器发布数据库脚本准备好后导入推荐用命令行而不是图形工具复制粘贴因为命令行能明确指定字符集# 1. 先确认 MySQL 服务在运行 mysql -uroot -p # 2. 在 mysql 命令行里执行脚本 mysql source /path/to/finance_db.sql; # 3. 或者不进入交互直接用重定向导入 mysql -uroot -p --default-character-setutf8mb4 /path/to/finance_db.sql # 4. 验证导入结果 mysql -uroot -p -e USE finance_db; SHOW TABLES; SELECT COUNT(*) FROM account_subject;导入过程中文变问号时优先排查两件事文件本身编码是否为 UTF-8Windows 记事本默认可能存成 GBK用编辑器转成 UTF-8 无 BOM 再导导入命令有没有带--default-character-setutf8mb4。这两个坑症状完全一样原因完全不同按顺序排查基本十分钟定位。部署方面原生Servlet项目最终打成 war 包放到 Servlet 容器的应用发布目录下。war 包的正确结构长这样finance.war ├── index.jsp ├── WEB-INF │ ├── web.xml │ ├── classes │ │ ├── controller/*.class │ │ ├── service/*.class │ │ ├── dao/*.class │ │ └── util/*.class │ └── lib │ └── mysql-connector-java-xxx.jar └── css/js/jsp文件最典型的部署翻车点JDBC 驱动 jar 没放在WEB-INF/lib下而是放在容器公共目录本地 IDE 开发时 classpath 正常一部署就报ClassNotFoundException。第二个翻车点是 context path部署名是finance访问地址必须带/finance/前缀代码里跳转如果写死/index.jsp登录成功后必然 404。写跳转统一用req.getContextPath()拼接从根上避开。用注解WebServlet的类要求 Servlet 3.0 以上容器老容器只认web.xml配置这也是 A同学当时在老旧环境部署失败的原因。5. 财务系统踩坑排查5个高发故障的现象、原因与修复5.1 页面乱码与金额精度丢失字符集和BigDecimal的锅现象登录成功后页面中文全部变成问号“???”或者金额100.10显示成100.1更严重的凭证两张明细加起来借贷差几分钱。原因乱码是字符集不一致——JSP 页面用 GBK 输出、Servlet 里setCharacterEncoding没写、数据库表是 latin1三处只要一处不一致中文必乱。金额问题则是用了double或float做金额运算二进制浮点数存不精确累计到一定量账面就不平。解决字符集三层统一——数据库连接 URL 带characterEncodingutf8Servlet 和 Filter 里统一request.setCharacterEncoding(UTF-8)、response.setContentType(text/html;charsetUTF-8)JSP 页面声明pageEncodingUTF-8。金额一律用BigDecimal数据库字段DECIMAL(14,2)查出来用rs.getBigDecimal()取而不是rs.getDouble()。排查顺序我一般固定为先看响应头编码再看连接串最后看表结构按这个顺序走很快能定位。5.2 事务像没写一样连接管理和自动提交的坑现象代码里明明写了setAutoCommit(false)和commit()凭证主表插入成功的同时明细表报错主表记录却留在库里没回滚。原因主表 DAO 和明细 DAO 各自调用DBUtil.getConnection()拿到了两个不同的物理连接commit 和 rollback 各管各的连接事务自然失效。另一种隐蔽情况是finally块里有人提前把连接 close 了连接一关后续操作被隐式提交。解决事务的黄金法则是“一个业务操作全程只用同一个连接”。常见做法是 Service 层获取连接后通过方法参数传给 DAO或者用线程绑定连接的方式传递。同时检查finally里的关闭顺序先ResultSet、再Statement、最后Connection反了会掩盖真实异常。排查时可以给连接打个日志输出 hashCode主表和明细的 hashCode 不一样问题就实锤了。5.3 SQL注入与XSS财务数据安全的第一道红线现象登录框输入 or 11加任意密码能直接登录在摘要框输入scriptalert(1)/script下次打开页面弹窗。原因登录 SQL 用了字符串拼接比如SELECT * FROM sys_user WHERE username username 输入值变成了 SQL 的一部分。XSS 则是用户输入原样输出到页面被浏览器当脚本执行。财务系统里这两者都不可接受——注入能拖库XSS 能劫持管理员会话。解决SQL 层全部用PreparedStatement占位符传参这是硬性要求不是可选项。输出层用户可控的内容摘要、科目名输出到页面时做 HTML 转义转lt;、转gt;。还有容易漏的地方导出 Excel 时摘要字段以开头会触发公式注入导出前也要做清洗。5.4 跨月查询结果不对日期边界与期间结转的处理现象查询 6 月凭证发现 7 月 1 日凌晨的数据被统计了或者 6 月 30 日下班后录入的几条凭证死活查不出来。原因日期条件写成了voucher_date 2024-06-30但表里如果是DATETIME类型2024-06-30会被解析成2024-06-30 00:00:006 月 30 日 0 点之后的记录全被漏掉反向场景是用了BETWEEN把边界日重复算了一遍。解决统一用半开区间voucher_date 2024-06-01 AND voucher_date 2024-07-01结束日期传下月 1 号。既覆盖 6 月 30 日 23:59:59又不带进 7 月数据。关联问题是月末结转把损益类科目余额结转到本年利润循环逐张凭证处理非常慢常见做法是用一条GROUP BY subject_code汇总 SQL 生成结转凭证性能差一个数量级。5.5 部署后404和驱动找不到war包结构与路径问题现象本地 IDE 运行正常部署到服务器后要么访问直接 404要么登录成功后跳到 404要么启动时ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因404 大概率是 context path 没对上——部署名是finance访问路径必须带/finance/前缀代码里跳转写死成了/index.jsp。ClassNotFound 是 JDBC 驱动 jar 没打进 war 的WEB-INF/lib。解决所有 Servlet 内跳转统一用req.getContextPath()拼前缀用jar tf finance.war | grep mysql检查驱动 jar 是否在包里没有就重新打包对老容器WebServlet注解不生效要在web.xml里补 Servlet 声明。部署后先看容器日志再试 URL绝大多数原因日志里直接写了比瞎试高效得多。6. 从能跑到能交付权限Filter、备份脚本与操作日志三件事系统能跑通之后离“能交付”还差三件事这三件决定了它是演示 Demo 还是能真用的工具。6.1 用Filter统一处理编码与登录校验前面几章的编码和登录校验分散在各个 Servlet 里每写一个重复一遍迟早漏一个。收敛到一个 FilterWebFilter(/*) public class EncodingAuthFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 1. 编码统一在这里处理Servlet里不用再重复写 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String uri request.getRequestURI(); // 2. 放行登录接口和静态资源其余全部拦截 if (uri.endsWith(/login) || uri.endsWith(login.jsp) || uri.contains(/css/) || uri.contains(/js/)) { chain.doFilter(req, resp); return; } // 3. 会话过期或未登录一律回登录页 HttpSession session request.getSession(false); Object loginUser (session null) ? null : session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }有了这个 Filter新写的 Servlet 不用再关心编码和登录校验只管业务。这是把横切逻辑从业务代码里拆出来的最朴素做法。6.2 mysqldump定时备份与还原验证财务数据丢不起备份是义务不是选项。最小成本方案是mysqldump加定时任务#!/bin/bash BACKUP_DIR/data/backup/finance DATE$(date %Y%m%d_%H%M%S) mkdir -p $BACKUP_DIR mysqldump -uroot -p数据库密码 \ --single-transaction \ --default-character-setutf8mb4 \ finance_db $BACKUP_DIR/finance_$DATE.sql find $BACKUP_DIR -name finance_*.sql -mtime 30 -delete echo 备份完成: $BACKUP_DIR/finance_$DATE.sql--single-transaction是 InnoDB 一致性快照备份不锁表不影响业务读写。更安全的做法是不把密码写进脚本改用 MySQL 配置文件里的[client]段或环境变量避免脚本泄露密码。备份脚本写完必须做一次还原演练导入到空库确认表和数据都在。没有验证过的备份等于没有备份。6.3 操作日志表让每一次记账都能追溯财务系统区别于普通管理系统的硬要求是审计谁在什么时间改过哪张凭证、改前改后各是什么值都要留痕。实现上不复杂建一张操作日志表记录操作人、操作时间、操作类型、操作详情四个要素插入日志的代码放进业务事务里和业务操作同生共死。这样操作失败日志也不落库保证日志和业务数据的一致性。再加一张日志查询页面管理员能按时间、按操作人、按凭证号筛选这套系统才具备真正交付的样子。说到这想起我自己的一个习惯几年前给某实验室做内部报销系统时功能做完就交付结果第二周对方打电话说数据对不上。查到最后是我把备份脚本写进了 crontab 但从来没验证过恢复流程备份出来的文件本身就是坏的。从那以后我给自己立了规矩——凡是涉及数据的功能交付前必须做一次从零恢复演练。这个习惯后来帮我避掉了不止一次麻烦。希望帮到你。本文还有配套的精品资源点击获取