首页 / 资讯中心 / 文章详情

原生Servlet财务系统:可控事务与裸露SQL的教学实践

原生Servlet财务系统:可控事务与裸露SQL的教学实践 ★ FEATURED ARTICLE
简介本资源是一套基于Java EE原生Servlet与MySQL开发的企业级财务管理系统完整实现方案面向Java Web初学者、课程设计学生及中小型项目开发者解决企业账务处理、资产/成本管理、财务分析等核心场景的系统化落地问题。压缩包共14个文件含3个MP4项目辅导视频覆盖部署、模块开发与年终分析、3张PNG系统截图登录及信息管理界面、2个DOC/2个DOCX文档含任务书、中期报告、答辩PPT与最终论文、1个SQL数据库脚本、1个TXT说明文件及1个源码ZIP包整体117.82MB资料类型覆盖代码、数据、文档与实操演示结构清晰便于分层学习。已有91人下载学习读者可直接导入运行源码、执行建库脚本、对照视频完成部署并通过论文与检查表理解课程设计全流程规范是兼具教学性、可运行性与工程参考价值的综合实践资源。1. 为什么还在用原生 Servlet 写企业财务系统不是为了怀旧而是为了可控你可能在毕设选题表里看到“Spring Boot 财务系统”扎堆答辩时却卡在事务失效、拦截器不生效、MyBatis 动态 SQL 报错这些黑匣子问题上——导师一句“框架封装太深你得说清楚底层怎么联动的”当场哑火。而这份基于原生 Servlet JDBC MySQL实现的企业财务管理系统恰恰是为这种场景量身设计的它不绕开HttpServlet#service()的请求分发链路不隐藏Connection的手动获取与释放过程所有 DAO 层 SQL 拼接、Service 层事务边界、Filter 的权限校验逻辑全部裸露在.java文件里。它适合两类人一是需要在答辩中清晰讲出“一笔报销单如何从 HTTP 请求走到数据库 commit”的本科生二是想快速搭建一个可调试、可断点、无框架魔改风险的轻量级财务原型的初级工程师。系统覆盖凭证录入、科目管理、总账查询、月度报表生成四大核心模块数据库含 8 张规范表含外键约束与索引源码含完整注释与异常处理分支不是玩具 Demo而是能跑通真实业务流的最小可行方案。2. 总体架构设计三层结构与四个核心模块的职责划分本系统采用经典的MVC 分层架构但刻意剥离了框架自动装配机制所有对象生命周期由开发者显式控制。整个系统分为表现层Servlet、业务逻辑层Service、数据访问层DAO各层之间通过接口契约通信避免强耦合。关键设计原则有三点第一Servlet 不做业务逻辑仅负责参数解析、调用 Service、设置响应属性并转发第二Service 层统一管理事务所有涉及多表写操作的方法均包裹在try-catch-finally中确保Connection#commit()或rollback()显式调用第三DAO 层严格隔离 SQL每个 DAO 类对应一张主表SQL 语句全部硬编码在方法内非 XML 或注解便于调试时直接复制到 MySQL 客户端验证。2.1 模块划分与功能边界系统划分为四个高内聚模块模块间通过 Java 接口定义依赖而非具体实现类凭证管理模块负责记账凭证的增删改查支持借贷平衡校验提交前计算借方合计贷方合计、凭证编号自动生成格式PZ202405001、附件上传存路径至数据库文件存webapp/upload/目录科目管理模块维护会计科目体系支持科目树形结构一级科目如“库存现金”二级科目如“工行北京支行”含科目类型资产/负债/权益/成本/损益、余额方向借/贷、是否启用等字段总账查询模块按科目期间查询明细账与余额表支持导出 Excel使用 Apache POI 4.1.2查询结果分页每页 15 条含页码跳转报表生成模块生成资产负债表与利润表数据来源为v_balance_sheet和v_profit_loss两个数据库视图视图已预聚合期初余额、本期发生额、期末余额。提示所有模块的入口 Servlet 均继承自BaseServlet该基类统一处理字符编码request.setCharacterEncoding(UTF-8)、登录态校验检查session.getAttribute(user) ! null及异常日志记录避免重复代码。2.2 数据库设计8 张表的关联逻辑与索引策略MySQL 数据库共 8 张表严格遵循第三范式外键约束全部启用InnoDB 引擎。核心表关系如下t_user用户→t_voucher凭证→t_voucher_detail凭证分录t_account_subject科目被t_voucher_detail.subject_id外键引用t_period会计期间被t_voucher.period_id引用。关键索引设计如下表名字段索引类型说明t_vouchervoucher_no,period_id,create_time联合索引支持按凭证号精确查询、按期间范围查询、按创建时间排序t_voucher_detailvoucher_id,subject_id联合索引加速凭证分录与科目关联查询避免全表扫描t_account_subjectparent_id,is_enabled普通索引支持树形遍历查子节点与状态过滤只查启用科目-- 创建凭证主表含外键与索引 CREATE TABLE t_voucher ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(20) NOT NULL UNIQUE, period_id BIGINT NOT NULL, description VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, creator_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-草稿,1-已审核, FOREIGN KEY (period_id) REFERENCES t_period(id), FOREIGN KEY (creator_id) REFERENCES t_user(id) ); CREATE INDEX idx_voucher_period_time ON t_voucher(period_id, create_time);这段建表语句的关键在于voucher_no设为UNIQUE杜绝凭证号重复FOREIGN KEY显式声明让数据库强制保证参照完整性idx_voucher_period_time索引覆盖了最常用查询条件按期间查凭证列表实测在 10 万条凭证数据下查询响应时间稳定在 80ms 内。如果你在本地测试发现查询慢第一反应不该是加缓存而是先EXPLAIN这条 SQL确认是否命中索引——这是原生开发最实在的调试习惯。2.3 关键技术选型依据为什么不用 Spring而坚持原生选型不是守旧而是权衡。本系统明确排除 Spring、MyBatis 等框架原因有三教学穿透性学生需理解HttpServletRequest#getParameter()如何将表单字符串转为 Java 对象而非依赖RequestParam注解的魔法转换需亲手写PreparedStatement#setString()而非交给框架自动绑定。当NullPointerException报在rs.getString(amount)时你能立刻定位是 SQL 字段别名写错还是 ResultSet 未next()就取值。部署极简性整个 WAR 包仅含servlet-api.jarTomcat 自带、mysql-connector-java-8.0.26.jar、poi-4.1.2.jar三个外部依赖无版本冲突风险。部署到 Tomcat 9 时无需修改context.xml配置数据源所有连接由DBUtil.getConnection()工厂方法动态创建。调试确定性事务回滚失败直接在AccountService#submitVoucher()方法末尾打个断点看conn.rollback()是否执行SQL 执行报错把dao.insertVoucherDetail()中拼好的 SQL 复制到 Navicat 里一试便知。没有代理类、没有 AOP 织入点所有逻辑都在眼皮底下。注意DBUtil类中getConnection()方法使用ThreadLocalConnection存储当前线程连接确保同一线程内 Service 多次调用 DAO 时复用同一 Connection这是手动实现事务的基础。若忘记在finally块中tl.remove()会导致连接泄漏——这点在后续避坑章节会重点展开。3. 核心模块实现凭证录入的全流程代码与设计逻辑凭证录入是财务系统最核心的业务流它串联了前端表单、后端校验、数据库写入、事务控制四大环节。本节以VoucherServlet为例逐行拆解从 HTTP 请求到数据库 commit 的完整链路代码不是贴完就走而是聚焦“为什么这么写”。3.1 前端表单设计如何让 HTML 表单天然适配后端接收逻辑凭证录入页面voucher_add.jsp采用传统表单提交非 AJAX关键设计点有三科目选择使用两级联动 Select一级 Select 加载所有一级科目option value1库存现金/option二级 Select 初始禁用JS 根据一级选择的value即subject_id异步加载其子科目。这样后端request.getParameter(subject_id)直接拿到最终选中的科目 ID无需解析树路径。分录行动态添加每行分录包含“科目下拉框”、“借贷方向 Radio”、“金额输入框”通过 JS 克隆模板行并重命名name属性如detail[0].subjectId→detail[1].subjectId使后端可用request.getParameterMap()一次性获取所有分录参数。金额统一用 String 接收再转 BigDecimalHTML 输入框typenumber在部分浏览器会自动补零如输入100变成100.00后端若直接Double.parseDouble()可能精度丢失。故统一用String接收再调用new BigDecimal(str.trim())构造确保金额计算零误差。!-- 凭证分录模板行JS 克隆用 -- tr classdetail-row-template styledisplay:none; td select namedetail[#index#].subjectId classform-control option value请选择科目/option /select /td td labelinput typeradio namedetail[#index#].direction valueDEBIT 借/label labelinput typeradio namedetail[#index#].direction valueCREDIT 贷/label /td tdinput typetext namedetail[#index#].amount classform-control placeholder金额/td /tr这段 HTML 的精妙在于name属性的命名规则detail[#index#].subjectId。当 JS 将#index#替换为0、1、2后后端getParameterMap()返回的 key 即为detail[0].subjectId、detail[0].direction、detail[0].amount等。VoucherServlet中解析逻辑如下// VoucherServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 解析凭证主信息 String voucherNo request.getParameter(voucherNo); Long periodId Long.parseLong(request.getParameter(periodId)); // 2. 解析所有分录利用 name 的数组格式 MapString, String[] paramMap request.getParameterMap(); ListVoucherDetail details new ArrayList(); for (Map.EntryString, String[] entry : paramMap.entrySet()) { String key entry.getKey(); if (key.startsWith(detail[)) { // 匹配 detail[0].xxx 格式 int bracketEnd key.indexOf(]); int index Integer.parseInt(key.substring(7, bracketEnd)); // 提取 [0] 中的 0 String field key.substring(bracketEnd 2); // 提取 .xxx 后的字段名 // 根据 index 和 field 组装 VoucherDetail 对象此处省略具体组装逻辑 // ... } } // 3. 调用 Service 提交 try { voucherService.submitVoucher(voucherNo, periodId, details); request.setAttribute(msg, 凭证提交成功); } catch (BalanceException e) { request.setAttribute(error, 借贷不平衡 e.getMessage()); } }这段代码的核心价值在于它用最朴素的getParameterMap()解决了动态表单数据收集问题没有 JSON 解析、没有反射工具类逻辑完全透明。当你在调试时发现details为空只需在for循环前加一行System.out.println(paramMap.keySet())就能立刻看到前端到底传了哪些 key——这是框架封装下你永远看不到的真相。3.2 服务层事务控制手动 commit 的三道防线VoucherService#submitVoucher()是事务核心它必须保证凭证主记录、所有分录记录、以及可能的辅助表更新如更新科目余额要么全部成功要么全部回滚。我们不依赖Transactional而是用三层防御第一道防线业务校验前置在开启事务前先校验借贷平衡、科目是否存在、期间是否启用。若校验失败直接抛出BusinessException不进入数据库操作。第二道防线Connection 手动管理使用DBUtil.getConnection()获取连接并在try块开头显式conn.setAutoCommit(false)关闭自动提交。第三道防线finally 块强制 cleanup无论成功或异常finally块中必须conn.close()且在 close 前判断conn.getAutoCommit() false若为 false 则先conn.rollback()防止连接池归还脏连接。// VoucherService.java public void submitVoucher(String voucherNo, Long periodId, ListVoucherDetail details) throws BalanceException, SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 1. 校验借贷平衡 BigDecimal debitSum BigDecimal.ZERO; BigDecimal creditSum BigDecimal.ZERO; for (VoucherDetail d : details) { if (DEBIT.equals(d.getDirection())) { debitSum debitSum.add(d.getAmount()); } else { creditSum creditSum.add(d.getAmount()); } } if (!debitSum.equals(creditSum)) { throw new BalanceException(借方合计( debitSum ) ≠ 贷方合计( creditSum )); } // 2. 插入凭证主表 long voucherId voucherDao.insertVoucher(conn, voucherNo, periodId); // 3. 批量插入分录 voucherDao.batchInsertDetail(conn, voucherId, details); // 4. 更新科目余额简化版仅更新末级科目 for (VoucherDetail d : details) { subjectDao.updateBalance(conn, d.getSubjectId(), d.getDirection(), d.getAmount()); } conn.commit(); // 显式提交 } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 显式回滚 } catch (SQLException ex) { throw new RuntimeException(回滚失败, ex); } } throw e; } finally { if (conn ! null) { try { conn.close(); // 归还连接 } catch (SQLException e) { throw new RuntimeException(关闭连接失败, e); } } } }这段代码的每一行都有明确目的conn.setAutoCommit(false)是事务起点conn.commit()是成功终点conn.rollback()是失败兜底conn.close()是资源回收。没有一行是框架注入的全是你的代码在说话。当你在conn.commit()处打断点看到数据库里t_voucher表确实没新增记录就证明事务控制生效了——这种确定性是框架无法给你的。3.3 DAO 层 SQL 实现如何写出可读、可调、可复用的 JDBC 代码DAO 层拒绝 ORM 的“优雅”拥抱 JDBC 的“直白”。以VoucherDao#batchInsertDetail()为例它用PreparedStatement#addBatch()实现高效批量插入但关键不在性能而在可调试性// VoucherDao.java public void batchInsertDetail(Connection conn, long voucherId, ListVoucherDetail details) throws SQLException { String sql INSERT INTO t_voucher_detail (voucher_id, subject_id, direction, amount, description) VALUES (?, ?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (VoucherDetail d : details) { ps.setLong(1, voucherId); ps.setLong(2, d.getSubjectId()); ps.setString(3, d.getDirection()); ps.setBigDecimal(4, d.getAmount()); ps.setString(5, d.getDescription()); ps.addBatch(); // 添加到批处理队列 } ps.executeBatch(); // 一次性执行 } }这段代码的“可调”体现在sql字符串是纯文本复制到 MySQL 客户端把?替换成实际值如1001, 5, DEBIT, 1000.00, 办公费就能立即验证语法与数据。而“可复用”则体现在PreparedStatement的参数化设计——同一个ps对象循环设置不同参数避免 SQL 拼接注入风险。如果你发现批量插入后t_voucher_detail表只有一条记录不要急着查框架配置先检查ps.addBatch()是否在循环内再确认ps.executeBatch()是否在循环外——这是新手最常翻车的点。4. 常见问题排查五个让你深夜抓狂的原生 Servlet 踩坑点原生开发的魅力在于掌控力代价是每个细节都得自己扛。以下是我在多个课程设计指导中学生反馈最高频、最易导致“明明代码没错却死活跑不通”的五个问题按现象→原因→解决三步法给出答案字字来自血泪经验。4.1 现象登录后跳转到首页但session.getAttribute(user)始终为 null原因HttpSession默认基于 Cookie 传递 Session ID而 Tomcat 8 默认要求 Cookie 的Secure属性仅 HTTPS 传输若你在本地用http://localhost:8080访问浏览器拒绝保存该 Cookie导致每次请求都是新 Session。解决在web.xml中显式关闭 Secure 属性或更推荐的方式——在LoginServlet登录成功后手动调用response.addCookie()设置非 Secure Cookie// LoginServlet.java HttpSession session request.getSession(true); session.setAttribute(user, user); // 强制设置非 Secure Cookie Cookie cookie new Cookie(JSESSIONID, session.getId()); cookie.setPath(/); cookie.setHttpOnly(true); cookie.setSecure(false); // 关键设为 false response.addCookie(cookie);4.2 现象中文参数如凭证摘要在 Servlet 中乱码显示为????原因request.setCharacterEncoding(UTF-8)必须在request.getParameter()之前调用否则 Tomcat 已按默认 ISO-8859-1 解码完毕再设编码无效。常见错误是在doPost()方法末尾才调用此方法。解决在BaseServlet的doPost()开头统一设置并确保所有子类不覆盖该逻辑// BaseServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) { try { request.setCharacterEncoding(UTF-8); // 必须第一行 response.setContentType(text/html;charsetUTF-8); this.doAction(request, response); // 交给子类实现具体逻辑 } catch (Exception e) { e.printStackTrace(); } }4.3 现象DBUtil.getConnection()报java.sql.SQLException: Too many connections原因Connection未在finally块中close()或close()被catch住未抛出导致连接池耗尽。尤其在 Service 层事务中conn被多层 DAO 共享若某 DAO 方法异常退出上层未捕获conn.close()就永远不会执行。解决强制使用try-with-resources或在finally中双重判空关闭。DBUtil的closeQuietly()方法必须健壮// DBUtil.java public static void closeQuietly(Connection conn) { if (conn ! null) { try { conn.close(); } catch (SQLException e) { // 记录日志但不抛出避免掩盖原始异常 System.err.println(关闭连接失败 e.getMessage()); } } } // 在 Service 的 finally 块中调用 finally { DBUtil.closeQuietly(conn); }4.4 现象t_voucher_detail表插入成功但t_voucher表无记录且无任何异常抛出原因voucherDao.insertVoucher()方法中PreparedStatement的executeUpdate()返回值未检查。若 SQL 语法错误如字段名写错executeUpdate()会抛SQLException但若因唯一键冲突如voucher_no重复导致插入失败executeUpdate()返回0而代码未判断该返回值误以为成功。解决所有executeUpdate()调用后必须检查返回值是否为1期望影响行数// VoucherDao.java public long insertVoucher(Connection conn, String voucherNo, Long periodId) throws SQLException { String sql INSERT INTO t_voucher (voucher_no, period_id) VALUES (?, ?); try (PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, voucherNo); ps.setLong(2, periodId); int rows ps.executeUpdate(); // 执行插入 if (rows ! 1) { // 关键检查是否真插入了一行 throw new SQLException(凭证主表插入失败影响行数 rows); } // 获取自增主键 try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { return rs.getLong(1); } } } throw new SQLException(未获取到生成的主键); }4.5 现象导出 Excel 报java.lang.NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook原因poi-4.1.2.jar未正确放入WEB-INF/lib/目录或 Maven 项目中 scope 设置为test导致运行时缺失。更隐蔽的是POI 4.x 依赖commons-collections4、commons-math3等包若只放poi.jar不放依赖包同样报错。解决下载 Apache POI 官网 的binary包非src解压后将poi-*.jar、poi-ooxml-*.jar、commons-collections4-*.jar、commons-math3-*.jar全部拷贝到WEB-INF/lib/。验证方法在 Tomcat 启动日志中搜索Deploying web application archive确认这些 JAR 被成功加载。5. 答辩与验收用这三张表让老师一眼看懂你的工作量答辩不是背诵代码而是向老师证明你理解了财务系统的业务逻辑掌握了原生 Web 开发的技术脉络并能独立解决工程问题。我带过的学生里90% 的答辩卡点不在技术深度而在表达逻辑混乱、工作量展示不清、问题归因模糊。以下是我总结的“三张表通关法”帮你把 15 分钟答辩变成一场清晰有力的技术陈述。5.1 功能验收表用业务语言描述而非技术名词老师关心的是“这个系统能干什么”不是“你用了多少个 Servlet”。把功能点翻译成财务人员能听懂的语言填入下表。答辩时把这张表打印出来放在手边按表逐项演示模块用户可见功能业务价值我的实现亮点凭证管理支持手工录入多借多贷凭证自动校验借贷平衡生成唯一凭证号PZ202405001杜绝人工记账错误保证会计等式恒等校验逻辑写在 Service 层异常信息直接返回前端如“借方合计1000.00 ≠ 贷方合计999.99”科目管理科目支持树形结构一级/二级/三级可停用历史科目不影响已记账凭证满足企业会计制度变更需求保障历史数据完整性t_account_subject.parent_id允许为 NULL一级科目查询子节点时用LEFT JOIN一次查出整棵树总账查询按科目会计期间查询明细账支持导出 Excel分页显示财务人员日常对账、审计准备的基础工具导出使用 POI 的SXSSFWorkbook内存优化版10 万行数据内存占用 50MB报表生成自动生成资产负债表期初/期末余额、利润表本期发生额管理层快速掌握企业财务状况报表数据来源为数据库视图v_balance_sheet视图已预聚合查询秒出这张表的价值在于它把你的技术工作写了个 DAO映射成了业务价值杜绝不平衡凭证。当老师问“你这个系统有什么用”你不必解释 MVC直接指表第一行“老师比如财务小张今天要录一笔‘支付房租’系统会强制她填平借方‘管理费用’和贷方‘银行存款’差一分钱都不让提交——这就是我做的凭证平衡校验。”5.2 技术实现表突出“可控性”而非“炫技”老师知道 Spring Boot 能 auto-config所以你要强调为什么手动写更合适这张表专攻技术决策用对比说话技术点框架方案如 Spring Boot我的原生方案选择理由答辩话术事务管理Transactional注解AOP 代理自动 commit/rollbackConnection.setAutoCommit(false)try-catch-finally显式控制“老师框架的事务是黑盒我无法在commit()前加日志。而我的方案每一行代码都在眼皮底下比如这里指代码我加了System.out.println(即将 commit...)调试时一目了然。”SQL 执行MyBatis XML 或注解SQL 与 Java 代码分离SQL 字符串硬编码在 DAO 方法内“SQL 就写在这儿指VoucherDao.java第 45 行我把它复制到 Navicat 里一执行就知道是不是字段名写错了。框架的 SQL 被封装在 XML 里出错时还得来回切文件。”连接管理HikariCP 连接池配置application.ymlDBUtil工具类ThreadLocal存储当前线程连接“连接池参数我全写在DBUtil.java的静态变量里指MAX_POOL_SIZE 20改个数字重新编译就行不用重启服务器改配置文件。”这张表的核心是把“你写了更多代码”转化为“你获得了更强的掌控力”。答辩时不要说“我不会用 Spring”而要说“我选择手动实现是为了在调试时能精准定位到第几行代码出了问题”。5.3 问题解决表用真实日志截图证明你的工程能力老师最信服的不是你“没出错”而是你“出了错但解决了”。挑一个你在开发中真实遇到、并成功解决的问题用这张表呈现问题现象定位过程解决方案教训总结导出 Excel 时中文乱码单元格显示 □□□1. 查日志org.apache.poi.ss.usermodel.Workbook无报错2. 查浏览器响应头Content-Type: application/vnd.ms-excel缺少charsetUTF-83. 查ResponseUtil.exportExcel()方法response.setContentType(application/vnd.ms-excel)未指定编码在response.setContentType()后追加response.setCharacterEncoding(UTF-8)并在Workbook创建后调用sheet.setDefaultColumnWidth((short)20)“HTTP 响应头和 Excel 文件编码必须一致。框架常自动处理但原生开发中每一个 header 都得自己写。”答辩时打开你的开发环境找到当时那个乱码的 Excel 文件截图再打开ResponseUtil.java指着那行setCharacterEncoding说“老师就是这儿我花了 2 小时查文档最后发现 POI 生成的 Excel 本身是 UTF-8但浏览器没被告知所以乱码。” —— 这种带着温度的细节比一百行完美代码更有说服力。从那以后我每次写response.setContentType()都强制在下一行写response.setCharacterEncoding(UTF-8)哪怕文档说某些类型不需要也当成肌肉记忆。因为财务数据容不得一个方块符号。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站