☰
无框架Servlet/JSP财务系统:凭证到报表、部署与二次开发
2026/10/31 15:34:39 网站建设 项目流程

简介:面向高校计算机、软件工程专业学生及Java EE入门开发者,这套原生Servlet+MySQL企业财务管理系统围绕毕业设计与课程设计场景,提供从需求文档到可运行项目的完整参考。系统采用模块化设计,涵盖账务处理、报表生成、预算控制、资产与成本分析等典型财务模块,适合学习Servlet请求处理、MySQL表结构设计与财务业务逻辑实现。压缩包共14个文件、约117.82MB,包含源码压缩包与数据库sql脚本,另有mp4操作录屏、doc/docx论文及任务书文档、ppt答辩材料、png界面截图和txt使用说明,便于部署复现与文档撰写。已有91人学习,可借助源码对照视频理解项目创建、数据库导入和登录流程,也能参考中期报告与答辩材料梳理设计思路。

1. 老项目交接现场:一套不靠框架的企业财务管理系统长什么样

去年帮人看一套从上一任开发者手里接过来的账务程序:没有 Spring,也没有 MyBatis,整个工程就是 Servlet + JSP + JDBC 加一个 MySQL 库,丢在 Tomcat 里跑。我第一反应是这东西怕是十年前的遗留物,翻完代码反而改了想法——这套「企业财务管理系统设计与实现」的路子,恰恰是能把 Web 底层讲透的样本。它是一套可直接部署的企业财务管理系统,业务覆盖会计科目、记账凭证、明细账、资产负债表和用户权限,源码附带库表脚本,导入就能跑起来。

适合谁看:一类是做课程设计或毕设、需要一个完整业务闭环的同学;一类是想搞明白「不用框架时一个请求到底经历了什么」的初中级开发者;还有一类是需要一套轻量财务底座、准备在上面加自己模块的小团队。反过来,指望开箱即用去做生产账务的人别抱期待——它没有审计留痕,没有分布式事务,也没对接任何外部财务软件,金额精度这块还得自己收拾。

2. 技术栈与目录结构:从 web.xml 到 DAO 的分层骨架

拿到源码先别急着点运行,先把分层看清楚。原生 Servlet 项目的分层是硬编码在包名和 web.xml 里的,看懂了包结构,后面改哪儿、加哪儿基本不用猜。

2.1 技术栈与运行环境清单

这个项目的选型非常「教科书」,好处是依赖极少,坏处是所有轮子都得自己造。下面是我实际跑通时用的环境组合,版本卡得不算严,但大版本别乱跳,尤其是 Tomcat 和 Servlet API 的对应关系。

层次技术 / 组件说明
表现层JSP + JSTL + 少量原生 JS页面直接放在 webapp 下,表单 POST 到 Servlet
控制层原生 Servlet 3.1用@WebServlet或 web.xml 注册,两种写法项目里都有
业务层手写 Service 类事务边界在这一层,不在 Servlet 里
持久层JDBC + 手写 DAO没有 ORM,SQL 全写在 DAO 的字符串里
数据库MySQL 5.7 / 8.0字符集必须 utf8mb4
容器Tomcat 8.5 / 9.0别用 Tomcat 10,包名从 javax 变 jakarta,全线报错
构建传统 Web 工程 / Maven 均可有 pom 就mvn package,没有就把 jar 塞 WEB-INF/lib

提示:Tomcat 10 是这套代码最容易踩的坑,javax.servlet.*在 10 里已经不存在了,一启动就是NoClassDefFoundError。新手直接上 8.5 或 9.0,省一半时间。

2.2 目录结构与分层职责

源码目录大致长这样,不同版本可能有细微差别,但包名语义基本一致:

finance-system/ ├── src/main/java/com/finance/ │ ├── web/ # Servlet:LoginServlet、VoucherServlet、SubjectServlet、ReportServlet │ ├── service/ # 业务编排 + 事务控制 │ ├── dao/ # 每个实体一个 DAO,SQL 全在这层 │ ├── entity/ # 与表一一对应的 POJO │ ├── filter/ # AuthFilter 登录拦截、EncodingFilter 编码 │ └── util/DBUtil.java # 取连接、关连接 ├── src/main/webapp/ │ ├── WEB-INF/web.xml # Servlet 映射、欢迎页、过滤器顺序 │ ├── login.jsp │ ├── voucher/ # 凭证录入、凭证列表 │ ├── subject/ # 科目维护 │ └── report/ # 明细账、资产负债表 └── sql/finance.sql # 建库建表 + 初始化科目和默认管理员

分层职责上有个细节值得注意:Servlet 只做三件事——取参数、调 Service、转发或重定向;所有金额计算、借贷平衡校验都在 Service;DAO 里只出现 SQL 和PreparedStatement赋值。这种写法看起来啰嗦,但改起来边界很清晰,比如要给凭证加一个「附单据张数」字段,改动路径是 entity → DAO 的 insert/select → JSP 表单,Servlet 那层几乎不用动。

web.xml里最关键的是过滤器顺序和 Servlet 映射,顺序错了会出现「编码过滤器在权限过滤器后面」导致跳转页乱码这种玄学问题:

<!-- 编码过滤器必须排在权限过滤器之前,否则重定向到登录页时中文参数会乱码 --> <filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.finance.filter.EncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>AuthFilter</filter-name> <filter-class>com.finance.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>VoucherServlet</servlet-name> <servlet-class>com.finance.web.VoucherServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>VoucherServlet</servlet-name> <url-pattern>/voucher/*</url-pattern> </servlet-mapping>

<url-pattern>/*</url-pattern>是拦截全站的前提,/voucher/*这种前缀匹配则让一个 Servlet 能用getPathInfo()区分 list、add、delete 等动作,项目里普遍用这种方式省掉一堆 Servlet 类。

2.3 数据库核心表设计:科目、凭证、分录三张主表

财务系统的数据模型其实就围绕「科目树 + 凭证头 + 凭证分录」展开,其余的表都是辅助。科目表存会计科目和借贷方向,凭证表存一张凭证的单头信息,分录表存这张凭证下的每一行借贷明细,三张表通过外键串起来。

CREATE TABLE fin_subject ( id INT PRIMARY KEY AUTO_INCREMENT, subject_code VARCHAR(20) NOT NULL UNIQUE COMMENT '科目编码,如 1001', subject_name VARCHAR(64) NOT NULL COMMENT '科目名称', parent_code VARCHAR(20) DEFAULT NULL COMMENT '上级科目编码,空表示一级科目', subject_type TINYINT NOT NULL COMMENT '1资产 2负债 3权益 4成本 5损益', balance_direction CHAR(1) NOT NULL DEFAULT 'D' COMMENT '余额方向 D借 C贷', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE fin_voucher ( id INT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(32) NOT NULL COMMENT '凭证字号,如 记-0001', voucher_date DATE NOT NULL, attachment_count INT DEFAULT 0 COMMENT '附单据张数', creator_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已审核 2已过账', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE fin_voucher_entry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_id INT NOT NULL, subject_id INT NOT NULL, summary VARCHAR(128) COMMENT '摘要', direction CHAR(1) NOT NULL COMMENT 'D借 C贷', amount DECIMAL(18,2) NOT NULL DEFAULT 0.00, KEY idx_voucher (voucher_id), KEY idx_subject (subject_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

金额字段用DECIMAL(18,2)而不是DOUBLE是硬性要求,浮点在累加几千条分录之后会出现分位误差,报表对不平的时候排查起来非常痛苦。status字段决定了报表统计口径:只有已过账(status=2)的凭证才进账簿,草稿状态的数据放进去会把资产负债表算错。索引方面fin_voucher_entry上必须建voucher_id和subject_id两个索引,否则明细账查询在几万条数据时就会明显卡顿。

3. 核心链路拆解:一张凭证从录入到进报表的完整路径

跑通了目录再看链路。财务系统的主干其实就一条:用户登录 → 录入凭证 → 借贷平衡校验 → 事务落库 → 报表按科目汇总。把这条线上每层做了什么拆开看,二次开发时就知道该往哪儿插代码。

3.1 登录与权限过滤:AuthFilter 里到底拦了什么

登录流程本身没什么特别,LoginServlet校验账号密码后把用户对象塞进 session,真正需要看的是过滤器。很多原生 Servlet 项目的权限控制写得很糙,要么全站放行要么连登录页都拦,结果就是死循环重定向。

public class AuthFilter implements Filter { // 白名单:登录页、登录接口、静态资源必须放行 private static final String[] WHITE_LIST = {"/login.jsp", "/login", "/css/", "/js/", "/images/"}; @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 去掉 contextPath,拿到纯业务路径,避免部署名变化后匹配失效 String uri = request.getRequestURI().substring(request.getContextPath().length()); for (String prefix : WHITE_LIST) { if (uri.startsWith(prefix)) { chain.doFilter(req, resp); return; } } // 注意用 false:没有会话就不要新建,否则每个未登录请求都会创建一个空 session HttpSession session = request.getSession(false); Object user = (session == null) ? null : session.getAttribute("LOGIN_USER"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

这里的逻辑分三步:白名单放行、取 session 判断登录态、未登录重定向。参数上最容易出问题的是getSession(false),写成getSession()或者getSession(true)会给每个匿名请求都创建一个 session,访问量稍大内存就上去了。request.getContextPath()的拼接也不能省,项目部署成/finance之后,直接sendRedirect("/login.jsp")会跳到根路径上,表现为 404,这种问题本地跑得好好的、一部署就翻车。

另外,这个过滤器只做「有没有登录」的判断,不区分角色。管理员和普通记账员看到的菜单是同一套,只是在 JSP 里用<c:if>藏了几个按钮——这属于前端隐藏,后端并没有拦住。二次开发要做的第一件事通常就是把角色校验补到 Servlet 层。

3.2 凭证录入:VoucherServlet 怎么接住动态表单

凭证录入页是一个动态增删行的表格,前端每加一行就多一组subjectId/summary/debit/credit输入框,提交上来是四个平行的数组。Servlet 侧的核心任务是把这些平行数组拼成对象列表,并在落库前做一次借贷平衡校验。

@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String voucherDate = req.getParameter("voucherDate"); String[] subjectIds = req.getParameterValues("subjectId"); String[] summaries = req.getParameterValues("summary"); String[] amounts = req.getParameterValues("amount"); String[] directions = req.getParameterValues("direction"); if (subjectIds == null || subjectIds.length == 0) { req.setAttribute("error", "至少录入一条分录"); req.getRequestDispatcher("/voucher/add.jsp").forward(req, resp); return; } List<VoucherEntry> entries = new ArrayList<>(); BigDecimal debitSum = BigDecimal.ZERO; BigDecimal creditSum = BigDecimal.ZERO; for (int i = 0; i < subjectIds.length; i++) { // 空行跳过:前端删除行后数组长度仍可能保留占位 if (subjectIds[i] == null || subjectIds[i].trim().isEmpty()) continue; VoucherEntry e = new VoucherEntry(); e.setSubjectId(Integer.parseInt(subjectIds[i])); e.setSummary(summaries[i]); e.setDirection(directions[i]); e.setAmount(new BigDecimal(amounts[i] == null ? "0" : amounts[i])); if ("D".equals(e.getDirection())) { debitSum = debitSum.add(e.getAmount()); } else { creditSum = creditSum.add(e.getAmount()); } entries.add(e); } // 借贷必须相等,且不能为全零,这是财务数据的底线校验 if (debitSum.compareTo(creditSum) != 0 || debitSum.compareTo(BigDecimal.ZERO) == 0) { req.setAttribute("error", "借贷不平衡:借方 " + debitSum + ",贷方 " + creditSum); req.getRequestDispatcher("/voucher/add.jsp").forward(req, resp); return; } Voucher voucher = new Voucher(); voucher.setVoucherDate(java.sql.Date.valueOf(voucherDate)); voucher.setEntries(entries); try { voucherService.saveVoucher(voucher, (User) req.getSession().getAttribute("LOGIN_USER")); resp.sendRedirect(req.getContextPath() + "/voucher/list"); } catch (SQLException ex) { req.setAttribute("error", "保存失败:" + ex.getMessage()); req.getRequestDispatcher("/voucher/add.jsp").forward(req, resp); } }

逐段看:setCharacterEncoding必须在第一次getParameter之前调用,否则后面所有中文摘要都会变成问号,而且过滤器里设过也不能省,因为请求一旦被读过参数,编码就定死了。getParameterValues拿到的是平行数组,索引对齐是关键,前端如果偷偷插入了一个隐藏行,这里就会错位,所以循环里加了空值跳过。

金额累加全程用BigDecimal,比较用compareTo而不是equals,因为new BigDecimal("100.0").equals(new BigDecimal("100.00"))返回 false,用 equals 判断平衡会出现「明明金额一样却提示借贷不平衡」的诡异现象。校验失败时用的是forward而不是redirect,这样req.setAttribute里放的错误信息还能在页面上显示出来。

3.3 DAO 与事务边界:JDBC 手写事务最容易漏的那一步

项目的持久层没有连接池(或者只用了简单的静态池),事务是手动控制的。核心原则是:一张凭证头加 N 条分录必须在同一个Connection上、同一个事务里完成,否则中途失败就会留下「有头没分录」的脏数据。

public boolean saveVoucher(Voucher voucher, User user) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务从这里开始 voucher.setCreatorId(user.getId()); voucher.setVoucherNo(voucherDao.nextNo(conn)); // 同一连接内取号,避免并发重号 int voucherId = voucherDao.insert(conn, voucher); for (VoucherEntry entry : voucher.getEntries()) { entry.setVoucherId(voucherId); entryDao.insert(conn, entry); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) { } } throw e; } finally { DBUtil.close(conn); // 归还前务必恢复 autoCommit=true } }

这段代码有几个必须说清的点。DAO 方法全部接收外部传入的Connection,而不是自己在方法内部DBUtil.getConnection(),否则每条 insert 都是独立事务,回滚等于没做。nextNo取凭证号的逻辑放在同一个连接里,避免「先查最大号再加一」被并发请求插队。finally里的close要在归还连接前把autoCommit改回true,不然这个连接被下一个请求复用时会带着「不自动提交」的状态,表现为别处的 insert 全部静默丢失——这种问题能查一整天。

补充一句,如果项目里配了连接池(常见做法是手写一个LinkedList<Connection>的简易池),close()的实现是「放回队列」而不是真的关掉,那么恢复autoCommit就更不能省。

3.4 报表汇总:一条 SQL 撑起资产负债表

报表页面上那张资产负债表的数字不是逐条累加出来的,而是一条分组聚合 SQL 直接算出来的。理解这条 SQL 的过滤条件,基本就理解了整个系统的数据口径。

SELECT s.subject_code, s.subject_name, s.subject_type, s.balance_direction, SUM(CASE WHEN e.direction = 'D' THEN e.amount ELSE 0 END) AS debit_total, SUM(CASE WHEN e.direction = 'C' THEN e.amount ELSE 0 END) AS credit_total FROM fin_voucher_entry e JOIN fin_subject s ON s.id = e.subject_id JOIN fin_voucher v ON v.id = e.voucher_id WHERE v.status = 2 -- 只统计已过账凭证 AND v.voucher_date BETWEEN ? AND ? -- 会计期间起止 GROUP BY s.subject_code, s.subject_name, s.subject_type, s.balance_direction ORDER BY s.subject_code;

status = 2是口径核心,草稿和已审核未过账的凭证不能进报表。BETWEEN ? AND ?用的是DATE类型,如果传进来的是字符串,MySQL 会隐式转换,在voucher_date上有索引的情况下可能导致索引失效,最好在 Java 侧用java.sql.Date显式传参。

余额的计算在 Java 侧完成:balance_direction = 'D'的科目用debit_total - credit_total,'C'的用credit_total - debit_total,结果为负说明该科目余额方向异常,报表上一般标红提示。资产类科目汇总到左栏,负债和权益汇总到右栏,两栏合计相等才算这张表编平了。实际跑起来经常不平,九成原因是有人手工往库里插了分录而没走 Service,或者科目表里一级科目的parent_code配错了导致汇总漏算。

4. 环境搭建与跑通:从空库到能登录的完整步骤

代码结构看完,接下来是把它跑起来。原生 Servlet 项目没有 Spring Boot 那种一键启动,步骤稍微多几步,但每一步都不难,关键是别跳步。

4.1 建库导数据与改连接参数

先把sql/finance.sql导入 MySQL。这个脚本通常包含建库、建表、初始化会计科目和一条默认管理员记录,导入完库里应该有十几张表和一套标准科目。

# 建库并导入,注意指定 utf8mb4,否则中文科目名会变问号 mysql -u root -p -e "CREATE DATABASE finance_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p --default-character-set=utf8mb4 finance_db < sql/finance.sql # 确认导入结果:科目表应该有数据,否则登录进去也录不了凭证 mysql -u root -p finance_db -e "SELECT COUNT(*) FROM fin_subject; SELECT id, username FROM sys_user;"

连接参数一般在DBUtil.java或db.properties里。JDBC URL 建议显式带上时区和编码,MySQL 8 的驱动对这两项比较敏感:

public class DBUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/finance_db" + "?useUnicode=true&characterEncoding=utf8mb4" + "&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); // MySQL 8 驱动类名 } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动未加载,检查 WEB-INF/lib 下有没有 mysql-connector-java"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException ignore) { } } } }

serverTimezone不配的话,MySQL 8 驱动在部分环境下会抛The server time zone value ... is unrecognized,而且这个报错跟业务代码毫无关系,很容易误判成 SQL 写错。allowPublicKeyRetrieval=true是应对 8.0 默认认证插件的一个常用开关,本地开发加上省事,生产环境要不要开自行判断。

4.2 部署到 Tomcat 的两种方式

有 Maven 的话最省事,mvn clean package打出 war 丢进webapps;没有 pom 的传统工程,就在 IDE 里配一个 Tomcat Server,把项目以 exploded 形式加进去。两种方式最终效果一样,区别只是热部署的便利程度。

# 方式一:war 包部署 mvn clean package -DskipTests cp target/finance-system.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh tail -f $TOMCAT_HOME/logs/catalina.out # 启动阶段盯这个日志 # 方式二:不解压直接指目录(IDE 里配 Deployment 等价于此) # 部署名建议显式指定,避免访问路径变成 /finance-system-1.0-SNAPSHOT

启动日志里要重点看三样:Deployment of web application archive ... has finished表示部署完成;有没有ClassNotFoundException;有没有Communications link failure。前者是依赖没进WEB-INF/lib,后者是数据库连不上,两者都跟 Servlet 代码本身无关,别一上来就怀疑业务逻辑。

访问路径是http://localhost:8080/<部署名>/login.jsp。如果部署名带版本号,路径会很长,建议在pom.xml里把<finalName>固定成短名字。

4.3 冒烟验证:按业务链走一遍而不是点首页

页面能打开不等于跑通了。我的习惯是按业务链走一遍,每步都验证数据库里有没有对应变化,这样能尽早发现事务和编码问题:

  1. 用默认管理员登录,看 session 里的用户名在页头是否正常显示中文(验证编码);
  2. 新增一个二级科目1002.01 银行存款-基本户,刷新列表确认落库;
  3. 录入一张凭证:借1002.011000 元,贷1001 库存现金1000 元,保存;
  4. 打开凭证列表,确认凭证号为记-0001且状态是草稿;
  5. 审核并过账,然后打开资产负债表,确认银行存款科目出现 1000;
  6. 故意录一张借贷不等的凭证,确认被拦住且页面有中文提示。

第 6 步尤其重要,它同时验证了校验逻辑、forward转发和错误信息回显三件事。第 3 步如果在数据库里只看到凭证头没有分录,别怀疑代码,先回去看saveVoucher有没有传同一个Connection。

5. 常见问题排查:部署阶段最容易翻车的五处

原生 Servlet 项目的报错信息普遍不友好,500页面后面往往只有一行堆栈。下面这几条是跑这套源码时高频出现的,按「现象 → 原因 → 解决」整理,遇到时可以直接对号入座。

5.1 中文全变问号,表单提交后数据库里也是乱码

现象:页面上的中文科目名显示正常,但提交保存后数据库里是????,重新查询出来也是问号。

原因:三个环节都可能漏了编码设置——JSP 页面没写pageEncoding、请求体没setCharacterEncoding、JDBC URL 没带characterEncoding。另外建库时如果默认字符集不是 utf8mb4,也会直接吃掉中文。

解决:JSP 头部加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Servlet 的第一行加req.setCharacterEncoding("UTF-8");连接串加useUnicode=true&characterEncoding=utf8mb4;最后确认库和表都是DEFAULT CHARSET=utf8mb4。四处齐了才不会复发。

5.2 启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver

现象:Tomcat 启动日志里一堆ClassNotFoundException,页面 500,堆栈指向DBUtil的静态代码块。

原因:MySQL 驱动 jar 没放进WEB-INF/lib。IDE 里把 jar 加进了 Build Path 只影响编译,Tomcat 运行时只认WEB-INF/lib下的包。用 Maven 的项目如果依赖 scope 写成provided,打 war 时同样不会带进去。

解决:把mysql-connector-java的 jar 复制到webapp/WEB-INF/lib/,或者把 pom 里的 scope 改成runtime(默认 compile 也行)。改完必须重新部署,光重启 Tomcat 不一定重新解包。

5.3 登录后随便点一下就跳回登录页

现象:登录成功跳到首页,点任意菜单又被打回login.jsp,像没登录一样。

原因:session 没保持住。常见于两种写法——AuthFilter里存的是request.getSession().setAttribute("user", ...),而别处取的是LOGIN_USER;或者 JSP 里<a href="/voucher/list">用了绝对路径,跳出了 contextPath 导致 cookie 路径对不上,服务端拿不到同一个会话。

解决:把 session key 抽成常量类统一引用,别在十几处地方手写字符串。链接一律用${pageContext.request.contextPath}/voucher/list拼接。顺带检查过滤器白名单里有没有误把/整段放行,那是另一个极端。

5.4 报表金额总是差几分钱

现象:资产负债表两栏合计相差 0.01,或者明细账逐条核对都对、合计对不上。

原因:中途有地方用了double或者在 SQL 里写了ROUND之外的隐式转换。JDBC 读DECIMAL用getDouble()也会丢精度,一累加就显出来。

解决:实体统一用BigDecimal,JDBC 用rs.getBigDecimal("amount"),累加用add,比较用compareTo或者setScale(2, RoundingMode.HALF_UP)之后再比。SQL 层不要写SUM(amount) * 1.0这类表达式,SUM直接对DECIMAL求和就是精确的。

5.5 保存凭证时报 Duplicate entry,或者分录丢了一半

现象:连续快速点击保存,报主键冲突;或者凭证头保存成功但分录只进了两三条。

原因:前者是取凭证号用了「先查 max 再加一」,两个请求并发时拿到同一个号;后者是commit()之前抛异常但没回滚,或者每条分录各自拿连接各自提交。

解决:凭证号改成在同一事务内取,或者用SELECT ... FOR UPDATE锁住序列表;分录插入必须共用 Service 传入的那个Connection,异常分支里显式rollback()。还有个小概率原因——finally里没有恢复autoCommit=true,连接池把脏状态传给了下一个请求,这属于血泪经验级别的问题,日志里看不出任何异常。

6. 二次开发改造:把单体财务系统改成能长期维护的样子

这套代码最大的价值不是功能,而是它足够朴素,朴素到你可以在上面做任何手术。我一般不会一上手就换框架,而是按「先止血、再加固、后扩展」的顺序改,每步都能单独验证,出问题好回退。

第一步是统一持久层入口,把散落在各个 DAO 里的DriverManager.getConnection()收口。最小改动是引入一个BaseDao,模板方法负责取连接、关连接,子类只写 SQL:

public abstract class BaseDao { // 查询模板:子类只实现 mapRow 和 sql,连接与异常处理统一在这 protected <T> List<T> queryList(String sql, RowMapper<T> mapper, Object... params) throws SQLException { List<T> result = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); // 1-based,下标从 1 开始 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { result.add(mapper.map(rs)); } } } return result; } }

这段代码的价值在于把try-with-resources固定下来,再也不会出现「忘了关 ResultSet」这种慢性泄漏。改造时建议先拿一个查询量最大的 DAO(通常是明细账查询)做试点,跑通再批量替换,别一次性全改。

第二步是加连接池和统一事务。项目原本手写的简易池没有超时和校验,换 HikariCP 只需要改DBUtil,业务代码零改动。事务这块我倾向引入一个极简的TxTemplate,把setAutoCommit/commit/rollback包起来,Service 层只写tx.execute(conn -> { ... }),避免每次复制粘贴那段 try-catch-finally。

第三步才是功能扩展。常见需求有两类:一是权限从「单角色」扩展到 RBAC,需要新增sys_role、sys_user_role、sys_permission三张表,并在AuthFilter里补一次 URL 级别的鉴权;二是报表口径变化,比如加现金流量表,本质上是新增一条按科目编码区间过滤的聚合 SQL,不动现有代码。

改造项改动范围验证方式
BaseDao 收口dao 包全部每个列表页翻一遍,对比改造前数据条数
换连接池仅 DBUtil压 100 次并发登录,看连接是否耗尽
事务模板service 包故意在第二条分录插错科目 id,确认整体回滚
RBAC 鉴权filter + 三张新表用低权限账号直连管理员 URL,确认被拦

最后一类是验证习惯。改完之后我会做一次「破坏性测试」:手工把fin_voucher_entry里某条分录的subject_id改成不存在的值,然后跑一遍报表——如果页面直接 500,说明关联查询没有容错;如果数字悄悄少了,说明用了内连接且没有任何告警。这个测试能暴露九成的数据口径问题。

后面这几次改造下来,我最深的一个教训是:原生 Servlet 项目的坑几乎都不在业务逻辑里,而在连接、编码、事务这三件基础设施上。从那以后我每次拿到这类源码,都会先在DBUtil和AuthFilter两个文件上花二十分钟通读一遍,再动手点运行,比直接启动省下的时间多得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询