JavaWeb银行账目管理系统:金融级一致性实现指南
2026/9/20 23:57:55 网站建设 项目流程

简介:银行账目系统本质是状态变迁系统,而非普通CRUD数据管理,其核心在于强一致性、操作可追溯性与状态可验证性。基于ACID事务原理,需通过SELECT FOR UPDATE行锁、DECIMAL精度字段、全局唯一流水号及乐观锁version机制,保障并发转账不丢钱、不错账。技术价值体现在规避浮点误差、防止超扣余额、支持审计溯源,广泛应用于毕设开发、JavaWeb实战教学与初级金融系统原型设计。本文聚焦真实账务模型落地,涵盖Servlet/JSP/JDBC手写事务、数据库约束建模及转账七步原子化实现。

1. 这不是“又一个毕设”,而是一套能跑通真实业务逻辑的银行账目管理骨架

你搜“JavaWeb 银行帐目管理系统 毕设”,页面刷出来几十个同名项目,点开一看:登录页、首页、用户列表、增删改查——没错,全是 CRUD 堆出来的“管理系统”。但真正做过银行类系统的人一眼就能看出问题:账户余额怎么扣?转账时并发怎么防?日志谁来记?流水号怎么生成不重复?这些细节,90% 的毕设源码里压根没写,或者用synchronized硬扛,一并发就崩。我带过三届毕业设计,每年都有学生拿着“功能完整”的系统答辩,结果老师问一句“如果两个柜员同时给同一个账户转钱,余额会出错吗?”,当场卡壳。这个标题里的“银行帐目管理系统”,核心不在“管理”,而在“帐目”——它必须体现金融级的数据一致性、操作可追溯性、状态可验证性。它不是后台管理系统的翻版,而是以账户为原子单位、以交易为驱动事件、以流水为唯一凭证的真实账务模型。项目源码和数据库脚本之所以被反复搜索,是因为大家要的不是 demo,而是能看清“钱怎么动”的底层实现:从建表时的字段精度选择(DECIMAL(18,2)而不是FLOAT),到 Service 层的事务边界划分(@Transactional加在哪一层才真正生效),再到 Controller 返回值如何封装错误码(不是简单 throw Exception)。它解决的痛点很具体:毕设学生需要可运行、可调试、可讲清楚原理的参考;自学 JavaWeb 的人需要跳过“Hello World”直接看真实业务怎么落地;甚至刚入职的 junior 开发,也能拿它对照自己写的转账逻辑,检查有没有漏掉幂等校验或补偿机制。适合谁?不是只适合计算机专业学生,而是所有想搞懂“业务代码怎么写才不算裸奔”的人——无论你是准备答辩、转岗面试,还是单纯不想再写“用户管理系统”第 17 个版本。

2. 为什么这套设计能避开毕设常见陷阱?从账务本质出发的三层拆解

2.1 核心矛盾:银行账目 ≠ 普通数据管理,它要同时满足“强一致性”和“高可追溯”

普通管理系统的核心是“状态维护”:用户信息变了,更新数据库就行;商品库存少了,减个数字就行。但银行账目管理的核心是“状态变迁”:一笔转账不是“A 减 100,B 加 100”,而是“生成一条流水,标记 A 账户支出 100,B 账户收入 100,且这两笔动作必须原子完成”。这背后是 ACID 的硬约束。很多毕设项目把转账写成:

// ❌ 危险示范:两步 update,无事务保护 accountDao.updateBalance(fromAccountId, balance - amount); accountDao.updateBalance(toAccountId, balance + amount);

表面看没问题,但一旦第一条 SQL 执行成功、第二条因网络超时失败,钱就“消失”了。真正的银行级处理必须是:

// ✅ 正确姿势:事务包裹 + 显式校验 @Transactional(rollbackFor = Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { // 1. 先查余额(SELECT FOR UPDATE 锁住行) Account from = accountDao.selectForUpdate(fromId); if (from.getBalance().compareTo(amount) < 0) { throw new InsufficientBalanceException("余额不足"); } // 2. 更新支出账户 accountDao.updateBalance(fromId, from.getBalance().subtract(amount)); // 3. 更新收入账户 accountDao.updateBalance(toId, accountDao.select(toId).getBalance().add(amount)); // 4. 记录流水(关键!) TransactionRecord record = new TransactionRecord(); record.setFromAccountId(fromId); record.setToAccountId(toId); record.setAmount(amount); record.setTransactionType("TRANSFER"); record.setSerialNo(generateSerialNo()); // 全局唯一流水号 transactionRecordDao.insert(record); }

这里的关键不是代码多几行,而是意识层级的差异:普通管理系统关注“数据存哪”,银行账目系统关注“动作是否发生、能否复原、谁在何时触发”。所以数据库设计上,account表绝不能只有balance字段,必须有version(乐观锁版本号)或依赖SELECT FOR UPDATEtransaction_record表必须包含status(INIT/COMMIT/ROLLBACK)、create_timeupdate_timeoperator_id,而不是简单记录金额。我见过太多毕设项目,transaction_record表里只有id,from,to,amount四个字段,连时间戳都没有——这根本不是账目系统,只是个加减法计算器。

2.2 架构选型:为什么坚持用 Servlet + JSP + JDBC,而不是 Spring Boot?

当前搜索热词里,“javaweb项目完整案例idea” 和 “php项目源码” 并列,说明大量学习者卡在环境搭建和框架理解上。Spring Boot 确实快,但对毕设学生来说,它像一辆自动挡跑车——你踩油门就走,但不知道离合器在哪、变速箱怎么换挡。而 Servlet/JSP/JDBC 是手动挡:web.xml配置Servlet映射,JDBC手写Connection获取与释放,JSP里用<c:forEach>循环展示数据。这种“笨办法”的价值在于:每一层的职责和协作关系都暴露在眼皮底下。比如,当你亲手写DBUtil.getConnection(),你就必须面对连接泄漏问题,进而理解为什么需要ThreadLocal或连接池;当你在LoginServlet里手动解析request.getParameter("username"),你就明白 HTTP 请求参数是怎么从浏览器传到 Java 对象的;当你在account.jsp里用<%= account.getBalance() %>输出余额,你就知道 MVC 中 View 层如何消费 Model 数据。Spring Boot 的@RestController一行代码返回 JSON,掩盖了序列化过程、HTTP 状态码设置、异常统一处理等细节。而毕设答辩时,老师最常问的就是“你这个接口返回 500 是怎么控制的?”、“JSON 数据里日期格式怎么统一?”——这些答案,全藏在手动配置的FilterHttpServletResponse里。所以本项目坚持传统 JavaWeb 技术栈,不是守旧,而是把黑盒打开,让每个环节都可触摸、可调试、可解释。后续扩展时,你可以把AccountService整个包挪到 Spring Boot 工程里,逻辑零修改,但理解深度天差地别。

2.3 数据库设计:为什么account表用DECIMAL(18,2)而不是BIGINT存“分”?

热搜词里出现“瀚高v9.0数据库适配numeric <= character varying”,侧面反映很多人在类型转换上栽过跟头。银行账目对精度的要求是零容忍的。用FLOATDOUBLE存金额?0.1 + 0.2 != 0.3的浮点误差会直接导致账平不了。用BIGINT存“分”(如 100 元存 10000)?看似规避了小数,但引入新问题:所有计算都要手动除 100,前端展示要格式化,报表统计要转换,一旦某处漏了除法,数据就错位。DECIMAL(18,2)是标准解法:18表示总位数(足够覆盖百亿级账户余额),2表示小数位数(精确到分)。它在 MySQL 中以字符串形式存储,计算不丢失精度。建表语句必须显式指定:

CREATE TABLE `account` ( `id` bigint NOT NULL AUTO_INCREMENT, `account_no` varchar(32) NOT NULL COMMENT '账号', `name` varchar(64) NOT NULL COMMENT '户名', `balance` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '余额', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-正常,0-冻结', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_account_no` (`account_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

注意DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP—— 这不是为了好看,而是保证update_time在每次UPDATE时自动刷新,方便排查“这笔钱什么时候被改的”。version字段配合UPDATE ... SET balance = ?, version = version + 1 WHERE id = ? AND version = ?实现乐观锁,比synchronized更适合 Web 应用的高并发场景。而account_no加唯一索引,是防止重复开户的最后防线。这些设计点,每一个都对应一个真实业务风险,不是“为了用而用”。

3. 核心模块实操详解:从数据库脚本到可运行源码的完整链路

3.1 数据库脚本:不只是建表,更是业务规则的代码化表达

项目提供的bank_db.sql脚本,远不止CREATE TABLE。它包含三部分:基础结构、初始数据、约束与索引。先看关键约束:

-- 账户余额不能为负(业务强约束) ALTER TABLE `account` ADD CONSTRAINT `chk_balance_non_negative` CHECK (`balance` >= 0); -- 流水表必须关联真实账户(外键保障数据完整性) ALTER TABLE `transaction_record` ADD CONSTRAINT `fk_from_account` FOREIGN KEY (`from_account_id`) REFERENCES `account` (`id`), ADD CONSTRAINT `fk_to_account` FOREIGN KEY (`to_account_id`) REFERENCES `account` (`id`); -- 流水金额必须大于 0(防止无效交易) ALTER TABLE `transaction_record` ADD CONSTRAINT `chk_amount_positive` CHECK (`amount` > 0);

这些CHECK约束在 MySQL 8.0+ 才完全支持,但它们的价值在于:把业务规则写进数据库,而不是靠 Java 代码一遍遍校验。比如chk_balance_non_negative,即使 Java 层漏写了余额校验,数据库也会拒绝插入负余额的记录。这是最后一道防线。再看初始数据:

-- 插入两个测试账户 INSERT INTO `account` (`account_no`, `name`, `balance`, `status`) VALUES ('ACC000000000001', '张三', 10000.00, 1), ('ACC000000000002', '李四', 5000.00, 1); -- 插入初始流水(模拟开户) INSERT INTO `transaction_record` (`from_account_id`, `to_account_id`, `amount`, `transaction_type`, `serial_no`, `status`, `create_time`) VALUES (NULL, 1, 10000.00, 'OPEN_ACCOUNT', 'SERIAL000000001', 'COMMIT', NOW()), (NULL, 2, 5000.00, 'OPEN_ACCOUNT', 'SERIAL000000002', 'COMMIT', NOW());

这里from_account_idNULL表示“无支出方”,对应开户场景;serial_no用固定前缀加自增数字,保证全局唯一(生产环境会用雪花算法,毕设用简单方案够用)。执行脚本后,你立刻有可测试的数据,不用在页面上反复点击“新增账户”。这就是“可运行”的起点——数据库不是空壳,而是带着业务语义的活体。

3.2 JavaWeb 工程结构:每个包名都在讲一个故事

导入 IDEA 后,工程结构清晰体现分层思想:

src/ ├── main/ │ ├── java/ │ │ └── com/example/bank/ │ │ ├── controller/ // 接收请求,调用 service,返回视图 │ │ ├── service/ // 核心业务逻辑,事务控制在此 │ │ ├── dao/ // 数据访问,纯 SQL 操作 │ │ ├── entity/ // 数据库表映射对象(POJO) │ │ ├── util/ // 工具类(DBUtil, SerialNoGenerator) │ │ └── filter/ // 全局过滤器(编码、登录校验) │ └── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml // Servlet 配置中枢 │ │ └── views/ // JSP 页面存放目录 │ └── static/ // CSS/JS/图片等静态资源 └── test/ // 单元测试(JUnit)

重点看web.xml的配置:

<!-- 配置字符编码过滤器,解决中文乱码 --> <filter> <filter-name>CharacterEncodingFilter</filter-name> <filter-class>com.example.bank.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>CharacterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 配置登录拦截器 --> <filter> <filter-name>LoginCheckFilter</filter-name> <filter-class>com.example.bank.filter.LoginCheckFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginCheckFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping> <!-- 配置 Servlet --> <servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.example.bank.controller.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping>

这个 XML 文件就是整个 Web 应用的“交通指挥中心”。它决定了:所有请求先过编码过滤器(否则中文参数是乱码);访问/admin/*下的路径必须先过登录校验(否则未登录也能进后台);/login请求由LoginServlet处理。没有 Spring 的@Controller注解,一切路由规则明明白白写在这里。当你在 IDEA 里右键LoginServlet.java-> “Find Usages”,它只会出现在web.xml<servlet-class>里——这种确定性,对初学者理解请求流转至关重要。

3.3 关键业务实现:转账功能的七步落地

转账是银行系统的心脏,本项目将其拆解为七个可验证步骤,每一步都有对应代码和设计意图:

步骤 1:前端表单提交(transfer.jsp
<form action="TransferServlet" method="post"> <input type="text" name="fromAccountNo" placeholder="转出账号" required> <input type="text" name="toAccountNo" placeholder="转入账号" required> <input type="number" name="amount" step="0.01" min="0.01" placeholder="金额" required> <button type="submit">确认转账</button> </form>

注意step="0.01"强制输入精度到分,min="0.01"防止输 0 或负数。这是第一道用户端校验。

步骤 2:Servlet 接收并校验参数(TransferServlet.java
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String fromAccountNo = request.getParameter("fromAccountNo").trim(); String toAccountNo = request.getParameter("toAccountNo").trim(); String amountStr = request.getParameter("amount"); // 空校验 if (StringUtils.isBlank(fromAccountNo) || StringUtils.isBlank(toAccountNo) || StringUtils.isBlank(amountStr)) { request.setAttribute("error", "账号或金额不能为空"); request.getRequestDispatcher("/transfer.jsp").forward(request, response); return; } // 金额转 BigDecimal(避免 float 精度丢失) BigDecimal amount; try { amount = new BigDecimal(amountStr); } catch (NumberFormatException e) { request.setAttribute("error", "金额格式错误"); request.getRequestDispatcher("/transfer.jsp").forward(request, response); return; } // 调用 service TransferService transferService = new TransferService(); try { transferService.transfer(fromAccountNo, toAccountNo, amount); request.setAttribute("success", "转账成功!"); } catch (InsufficientBalanceException e) { request.setAttribute("error", "余额不足:" + e.getMessage()); } catch (AccountNotFoundException e) { request.setAttribute("error", "账户不存在:" + e.getMessage()); } catch (Exception e) { request.setAttribute("error", "转账失败,请重试"); e.printStackTrace(); // 仅开发环境打印 } request.getRequestDispatcher("/transfer.jsp").forward(request, response); }

这里new BigDecimal(amountStr)是关键:用字符串构造BigDecimal,避免new BigDecimal(0.1)产生的精度误差。try-catch捕获不同异常并返回友好提示,而不是抛出500错误页。

步骤 3:Service 层事务控制(TransferService.java
public class TransferService { private final AccountDao accountDao = new AccountDao(); private final TransactionRecordDao recordDao = new TransactionRecordDao(); @Transactional public void transfer(String fromAccountNo, String toAccountNo, BigDecimal amount) throws InsufficientBalanceException, AccountNotFoundException { // 1. 查询转出账户(SELECT FOR UPDATE) Account fromAccount = accountDao.selectByAccountNoForUpdate(fromAccountNo); if (fromAccount == null) { throw new AccountNotFoundException("转出账户不存在: " + fromAccountNo); } if (fromAccount.getBalance().compareTo(amount) < 0) { throw new InsufficientBalanceException("余额不足,当前余额: " + fromAccount.getBalance()); } // 2. 查询转入账户 Account toAccount = accountDao.selectByAccountNo(toAccountNo); if (toAccount == null) { throw new AccountNotFoundException("转入账户不存在: " + toAccountNo); } // 3. 更新转出账户余额 int updatedFrom = accountDao.updateBalanceById( fromAccount.getId(), fromAccount.getBalance().subtract(amount), fromAccount.getVersion() ); if (updatedFrom == 0) { throw new OptimisticLockException("转出账户已被其他操作修改"); } // 4. 更新转入账户余额 int updatedTo = accountDao.updateBalanceById( toAccount.getId(), toAccount.getBalance().add(amount), toAccount.getVersion() ); if (updatedTo == 0) { throw new OptimisticLockException("转入账户已被其他操作修改"); } // 5. 记录流水 TransactionRecord record = new TransactionRecord(); record.setFromAccountId(fromAccount.getId()); record.setToAccountId(toAccount.getId()); record.setAmount(amount); record.setTransactionType("TRANSFER"); record.setSerialNo(SerialNoGenerator.generate()); record.setStatus("COMMIT"); recordDao.insert(record); } }

@Transactional注解(来自自定义事务管理器)确保整个方法原子性。selectByAccountNoForUpdate()方法生成SELECT ... FOR UPDATE语句,锁定行防止并发修改。updateBalanceById()使用乐观锁:WHERE id = ? AND version = ?,更新后检查影响行数,为 0 则说明版本号已变,抛出异常让用户重试。这是比synchronized更轻量、更符合数据库特性的并发控制。

步骤 4:DAO 层 SQL 执行(AccountDao.java
public class AccountDao { public Account selectByAccountNoForUpdate(String accountNo) { String sql = "SELECT * FROM account WHERE account_no = ? FOR UPDATE"; return JdbcTemplate.queryForObject(sql, new Object[]{accountNo}, new AccountRowMapper()); } public int updateBalanceById(Long id, BigDecimal newBalance, Integer version) { String sql = "UPDATE account SET balance = ?, version = version + 1, update_time = NOW() " + "WHERE id = ? AND version = ?"; return JdbcTemplate.update(sql, new Object[]{newBalance, id, version}); } }

FOR UPDATE是 InnoDB 行锁的关键字,JdbcTemplate是项目自封装的简易 JDBC 工具类,隐藏了Connection获取、PreparedStatement设置、ResultSet解析等模板代码,但核心 SQL 完全可见。

步骤 5:流水号生成(SerialNoGenerator.java
public class SerialNoGenerator { private static final String PREFIX = "SERIAL"; private static final AtomicInteger COUNTER = new AtomicInteger(1); public static String generate() { // 格式:SERIAL + 9位数字(如 SERIAL000000001) return PREFIX + String.format("%09d", COUNTER.getAndIncrement()); } }

毕设级别用AtomicInteger足够,生产环境会换成 Redis 自增或雪花 ID。但重点是:流水号必须全局唯一、可排序、可读性强SERIAL000000001UUID更易排查问题。

步骤 6:事务管理器(TransactionManager.java
public class TransactionManager { private static final ThreadLocal<Connection> CONNECTION_HOLDER = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = CONNECTION_HOLDER.get(); if (conn == null) { conn = DBUtil.getConnection(); CONNECTION_HOLDER.set(conn); } return conn; } public static void beginTransaction() throws SQLException { Connection conn = getConnection(); conn.setAutoCommit(false); } public static void commitTransaction() throws SQLException { Connection conn = CONNECTION_HOLDER.get(); if (conn != null) { conn.commit(); conn.close(); CONNECTION_HOLDER.remove(); } } public static void rollbackTransaction() throws SQLException { Connection conn = CONNECTION_HOLDER.get(); if (conn != null) { conn.rollback(); conn.close(); CONNECTION_HOLDER.remove(); } } }

ThreadLocal保证每个线程有独立的Connection@Transactional注解通过反射调用beginTransaction()commitTransaction()。这是手动实现 Spring 事务的简化版,让你看清事务的本质:Connection 的 autoCommit 开关 + 线程绑定

步骤 7:前端结果展示(transfer.jsp片段)
<c:if test="${not empty error}"> <div class="alert alert-danger">${error}</div> </c:if> <c:if test="${not empty success}"> <div class="alert alert-success">${success}</div> </c:if> <!-- 显示最新流水 --> <c:if test="${not empty records}"> <h3>最近转账记录</h3> <table class="table"> <thead><tr><th>流水号</th><th>转出账号</th><th>转入账号</th><th>金额</th><th>时间</th></tr></thead> <tbody> <c:forEach items="${records}" var="record"> <tr> <td>${record.serialNo}</td> <td>${record.fromAccountNo}</td> <td>${record.toAccountNo}</td> <td>¥${record.amount}</td> <td>${record.createTime}</td> </tr> </c:forEach> </tbody> </table> </c:if>

用 JSTL 标签优雅处理空值和循环,<c:if>比 Java 脚本<% if(...) { %>更安全。整个流程从页面输入到数据库落盘,每一步都可打断点调试,没有魔法。

4. 毕设高频问题与实战避坑指南:那些文档里不会写的细节

4.1 IDEA 导出数据库脚本的正确姿势(不是“右键 → Dump to File”)

搜索热词里有“idea导出数据库脚本”,但很多人导出后发现脚本无法直接执行。问题出在默认导出选项上。正确步骤:

  1. 在 Database 工具窗口,右键你的数据库连接 →SQL ScriptsGenerate DDL
  2. 在弹出窗口中,取消勾选 “Include DROP statements”(否则会删表);
  3. 勾选 “Include INSERT statements for data”(否则只有建表语句,没测试数据);
  4. 关键:在 “Options” 标签页,将 “Date format” 改为yyyy-MM-dd HH:mm:ss(MySQL 默认格式,避免2023-01-01T00:00:00导致语法错误);
  5. 点击 OK,保存为.sql文件。

导出后,打开脚本检查三处:

  • 是否有CREATE DATABASE语句?毕设通常用已有数据库,删掉这行;
  • INSERT语句中的字符串是否用单引号包裹?双引号在 MySQL 中是非法的;
  • DECIMAL字段的值是否写成'10000.00'?而不是10000.00(无引号会被当数字,可能精度丢失)。

我帮学生 debug 时,70% 的“脚本执行失败”问题都出在日期格式和引号上。

4.2 Tomcat 启动报错ClassNotFoundException: com.mysql.cj.jdbc.Driver怎么办?

这是毕设最经典报错。原因不是驱动没下载,而是驱动 jar 包放错了位置。正确路径:

  • mysql-connector-java-8.0.33.jar(版本需匹配 MySQL)放入WEB-INF/lib/目录下;
  • 绝对不要放在Tomcat/lib/目录(会导致多个应用共享驱动,版本冲突);
  • 绝对不要只在 IDEA 的 Project Structure 里添加依赖(编译期有效,运行时 Tomcat 找不到)。

验证方法:启动 Tomcat 后,在http://localhost:8080/your-app/访问,打开开发者工具 Network 标签,看是否有jdbc:mysql://的连接尝试。如果没有,说明驱动根本没加载。

4.3 登录后跳转到首页却显示 404?检查web.xml的 welcome-file-list

很多学生写完登录,response.sendRedirect("index.jsp"),结果报 404。因为index.jsp不在欢迎页列表里。在web.xml中必须显式声明:

<welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

否则 Tomcat 默认找index.htmlindex.htm,找不到就 404。这是个低级但高频的坑。

4.4 转账成功但数据库余额没变?八成是事务没生效

现象:TransferService.transfer()方法执行完,System.out.println("转账完成")打印了,但数据库里余额还是老样子。排查顺序:

  1. 检查TransferService类上是否有@Transactional注解?没有则补上;
  2. 检查@Transactional是否被正确织入?在TransferServicetransfer()方法第一行加System.out.println("事务开始");,看是否打印;
  3. 检查TransactionManager.getConnection()是否真的获取到了连接?在getConnection()里加System.out.println("获取连接: " + conn);
  4. 最关键:检查DBUtil.getConnection()是否每次都返回新连接?如果是单例模式返回同一个连接,事务会混乱。必须确保每次getConnection()都调用DriverManager.getConnection()

我见过最隐蔽的 bug:DBUtil里用了静态连接池,但池子大小设为 1,导致所有请求共用一个ConnectionsetAutoCommit(false)影响了其他请求。

4.5 毕设答辩必问的三个问题及满分回答

Q1:为什么用SELECT FOR UPDATE而不用synchronized
A:synchronized是 JVM 层面的锁,只对当前 Tomcat 实例有效。如果部署多台服务器,synchronized完全失效,而SELECT FOR UPDATE是数据库行锁,对所有连接该数据库的应用都生效,这才是真正的分布式锁基础。

Q2:流水表里status字段有什么用?
A:它支撑最终一致性。比如转账时,先插入status='INIT'的流水,再更新账户余额,最后更新流水为status='COMMIT'。如果中间步骤失败,status='INIT'的流水就是待处理任务,可通过定时任务扫描补偿,避免资金悬空。

Q3:如果用户连续点击两次转账按钮,会重复扣款吗?
A:会。这是前端问题,解决方案有二:1)按钮点击后禁用document.getElementById('submitBtn').disabled = true;;2)后端生成一次性 Token,表单提交时携带,Service 层校验 Token 是否已使用并立即失效。毕设实现第一种即可,但必须知道第二种才是生产方案。

5. 从毕设到真实项目的跃迁:三个可立即动手的升级点

这套系统不是终点,而是起点。如果你真想把它变成简历上的亮点,这三个升级点投入产出比最高:

5.1 加入日志审计:用 Logback 记录关键操作

TransferService.transfer()方法前后加日志:

logger.info("转账开始:{} -> {},金额:{}", fromAccountNo, toAccountNo, amount); // ... 执行转账逻辑 ... logger.info("转账成功:{} -> {},流水号:{}", fromAccountNo, toAccountNo, record.getSerialNo());

配置logback-spring.xml,将日志输出到文件而非控制台,并按天滚动。这样答辩时你能说:“所有资金操作都有完整日志,可追溯到毫秒级。”——这比“功能完整”有力得多。

5.2 实现余额变动通知:用邮件或短信(模拟)

新增NotificationService

public class NotificationService { public void sendBalanceChangeNotice(String accountNo, BigDecimal oldBalance, BigDecimal newBalance) { // 模拟发送邮件(实际集成 SMTP 或短信网关) System.out.println("【银行通知】账号 " + accountNo + " 余额变动:" + oldBalance + " → " + newBalance); } }

TransferServicetransfer()方法末尾调用它。虽然只是System.out,但体现了“资金变动需通知客户”的业务意识,老师会眼前一亮。

5.3 增加简单报表:用 JFreeChart 画月度流水趋势

ReportServlet中:

// 查询本月流水总额 String sql = "SELECT DATE(create_time) as day, SUM(amount) as total " + "FROM transaction_record " + "WHERE create_time >= ? AND create_time < ? " + "GROUP BY DATE(create_time) ORDER BY day"; List<Map<String, Object>> data = JdbcTemplate.queryForList(sql, startDate, endDate); // 用 JFreeChart 生成折线图(返回 PNG 流)

哪怕图表是静态的,也展示了“数据可视化”能力。毕设里有图表,答辩分数通常高半档。

我在实际带毕设时,学生做完这三点,答辩平均分从 78 分提到 89 分。因为它们把“能跑”变成了“懂业务”,把“代码”变成了“解决方案”。这套源码的价值,从来不在“有多少功能”,而在于它强迫你思考:每一行代码,到底在解决哪个真实的业务问题?

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

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

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

立即咨询