☰
Java课程设计避坑指南:ATM模拟系统中的金额精度与事务边界
2026/11/2 18:04:55 网站建设 项目流程

简介:这份资源是面向高校计算机专业学生与Java初学者的课程设计完整文档,围绕ATM柜员机模拟系统展开,帮助读者在掌握面向对象编程思想的同时完成一次贴近真实银行业务的项目实践。压缩包内共1个docx文件,约1.21MB,内容涵盖需求分析、界面设计、功能实现与项目总结四大模块,具体包括用户登录、账户查询、存款、取款、更改密码等功能设计,以及取款存款核心代码、逻辑校验思路、SQL语句示例和功能结构图,并配有目录与参考文献,结构完整、层次清晰。已有387人学习下载,适合需要参考课程设计写作框架、梳理Swing界面开发流程或对照代码理解业务逻辑的读者,可直接借鉴其分析思路与文档组织方式,用于自己的课程设计或项目练习。

1. ATM 模拟系统:为什么“余额不足”这四个字最难写对

很多人第一次做 Java 课程设计,看到“ATM 柜员机模拟系统”会觉得简单:登录、查询、存取款、改密,五个按钮五个窗口,Swing 拖一拖就完事。真正动手才发现,界面反而是最不费脑子的部分,真正让人翻车的是那句“余额不足”——它背后牵扯的是金额精度、并发更新、校验顺序和事务边界。一个能跑起来的 Demo 和一个“敢让人乱点”的系统,差距全在这些看不见的地方。

这份课程设计资源的价值不在于它用了多少高级框架,而在于它把一个金融类业务的最小闭环走完了:Swing 做界面、CardLayout 做窗口切换、Service 层做逻辑校验、MyBatis 映射 SQL 落库。它把“界面事件如何驱动业务逻辑、业务逻辑如何安全地改数据库”这条链路完整暴露出来,正好适合拿来拆解底层机制。搞懂它,你就明白为什么金额不能用 double、为什么校验要分先后、为什么一次取款必须包在一个会话里。

2. 分层与窗口切换:Swing 的事件模型到底在替谁干活

2.1 CardLayout 的本质是“同一容器里的多张卡片”

这个系统用 1 个主窗口加 5 个功能窗口,主窗口上排按钮,点击后切换内容。常见的实现思路不是开 6 个 JFrame 互相跳转,而是用一个 JFrame 装一个 CardLayout 容器,每个功能面板作为一张“卡片”塞进去,切换时只换显示的那张。这样做的好处很直接:窗口状态、背景、标题都在同一个容器里管理,不会出现“点了三次取款弹出三个窗口”的经典 bug。

从源码里能看到ChangeMoneyPanel的构造函数签名:它接收Container contentPane、CardLayout cardLayout、MainView mainView三个参数。这不是随便传的——contentPane是卡片容器本体,cardLayout是切换器,mainView是持有当前登录用户currentId的上下文对象。面板自己不存用户身份,而是回头问主视图要,这就是最朴素的“上下文传递”。

// 面板构造时注入容器、布局管理器和主视图上下文 public ChangeMoneyPanel(String type, Container contentPane, CardLayout cardLayout, MainView mainView) { setLayout(null); // 绝对定位,配合 setLocation/setSize final String substring = type.substring(0, 1); // "存入"->"存","取出"->"取" // ... 组件创建与定位 ... backButton.addActionListener(e -> { mainView.setTitle("菜单"); cardLayout.show(contentPane, "菜单"); // 按卡片名切回主菜单 moneyText.setText(""); // 清空输入,避免残留 }); }

这里有个容易被忽略的细节:type.substring(0, 1)把“存入/取出”截成“存/取”,用来动态拼提示语和按钮文字。这种“一个面板类复用两种业务”的写法省了代码,但也埋了坑——后面校验逻辑里type.equals("存入")一旦写错一个字,存钱就变成取钱。参数type实际承担了“模式开关”的角色,属于用字符串当枚举用的典型课程设计手法。

2.2 事件监听器里为什么不能直接写 SQL

看enterButton的监听器,它没有直接操作数据库,而是new HandleServiceImpl()然后调addMoney/minusMoney,拿回一个Result<Boolean>,根据data决定是否backButton.doClick()返回菜单。这个结构值得说透:Swing 的事件分发线程(EDT)是单线程的,所有界面重绘和事件回调都排在这一个线程上。如果在监听器里直接跑一条慢 SQL,整个界面会卡死,用户以为程序崩了。

所以合格的写法是把“界面响应”和“业务执行”分开:监听器只负责收集输入、调用服务、根据结果弹提示。Result这个返回类型是这套代码的“契约”——它同时携带massage(原文拼写如此)和data,界面层不需要知道失败原因是余额不足还是字符非法,只管把massage弹出来。这种“服务层返回结构化结果、界面层只做展示”的分工,是分层最实在的收益。

提示:JOptionPane.showMessageDialog是模态的,弹窗期间 EDT 被占用,但用户无法再点其他按钮,反而避免了重复提交。这是课程设计里“歪打正着”的并发保护。

2.3 绝对定位布局的取舍

代码里满屏setLocation/setSize,setLayout(null)关掉了布局管理器。教学项目爱这么写,因为所见即所得、不用理解 BoxLayout 和 GridBagLayout 的约束规则。代价是窗口一拉伸组件就错位,换个分辨率字体就挤在一起。这不是不能接受——课程设计的窗口尺寸固定,绝对定位反而让“背景图 + 文字 + 输入框”的叠放关系一目了然。但如果要把它改成可缩放界面,第一件事就是把null布局换成GridBagLayout,否则血泪经验马上就来。

3. 金额校验链:为什么 double 是金融系统的原罪

3.1 校验顺序本身就是设计

addMoney和minusMoney的校验顺序几乎一模一样:先判空、再判是否纯数字、再判是否整百。这个顺序不是随手排的。判空放最前,是因为money.equals("")对 null 会抛空指针,而输入框getText().trim()至少返回空串,所以先挡空串最安全。判纯数字放第二,是因为Double.parseDouble遇到字母直接抛NumberFormatException,必须在解析前拦住。整百判断放最后,因为它依赖解析后的数值。

@Override public Result<Boolean> addMoney(Long id, String money) { if (money.equals("")) { return new Result<>("不能为空", false); } if (!StringUtils.isStrictlyNumeric(money)) { // 自定义严格数字校验 return new Result<>("含有非法字符", false); } if (Double.parseDouble(money) % 100 != 0) { // ATM 只收百元整钞 return new Result<>("ATM 中只支持百元大钞,零钱请前往柜台领取", false); } SqlSession sqlSession = sqlSessionFactory.openSession(); MysqlOperateMapper userMapper = sqlSession.getMapper(MysqlOperateMapper.class); userMapper.addMoneyById(id, new BigDecimal(money)); sqlSession.commit(); sqlSession.close(); return new Result<>("存款成功", true); }

注意isStrictlyNumeric这个自定义工具方法:它比Double.parseDouble更严格,能挡住"1e3"、" 12 "、"0x10"这类解析器能吞但业务不该接受的输入。参数money是字符串而非数值,正是为了“先校验、后转换”,把非法输入挡在类型转换之前。

3.2 BigDecimal 与 double 的分水岭

校验里用Double.parseDouble(money) % 100做整百判断,落库时却用new BigDecimal(money)。这个混用暴露了一个关键认知:double是二进制浮点,0.1 + 0.2不等于0.3,用它存钱,几次存取之后余额就会出现999.9999999999999这种鬼数字。BigDecimal是十进制精确表示,配合数据库的DECIMAL字段才能保证“一分不多一分不少”。

但BigDecimal也有讲究:new BigDecimal("100.00")和new BigDecimal(100.00)结果不同,后者会把 double 的误差原样带进来。所以正确姿势永远是从字符串构造。这套代码在落库处用了字符串构造,算是踩对了点;整百判断用 double 只是因为% 100对整数部分够用,属于“局部妥协”。

3.3 取款的余额比较:compareTo 而不是 equals

minusMoney里最关键的一行是user.getMoney().compareTo(new BigDecimal(money)) < 0。为什么不用equals?因为BigDecimal的equals连精度(scale)都比:100和100.00用equals判不相等,但业务上它们是同一个金额。compareTo只比数值大小,返回负数表示前者小于后者,正好对应“余额不足”。

User user = userMapper.selectById(id); if (user.getMoney().compareTo(new BigDecimal(money)) < 0) { return new Result<>("余额不足", false); // 先查后改,读到的余额是判断依据 } userMapper.minusMoneyById(id, new BigDecimal(money)); sqlSession.commit();

这里有个隐蔽的时序问题:先selectById读出余额,再minusMoneyById做减法,两步之间如果另一个请求也取款,就可能双双通过余额检查,造成透支。课程设计单用户操作碰不到,但这是理解“为什么真实系统要用update ... where money >= ?原子扣减”的最佳入口。SQL 层写成money = money - #{money}本身是原子的,问题出在“检查”和“扣减”被拆成了两条语句。

4. SQL 与持久化:一条 update 背后的会话生命周期

4.1 手写 MyBatis 映射的极简形态

持久层没有用 Spring 托管,而是手动openSession→getMapper→ 执行 →commit→close。这套流程把 MyBatis 的会话生命周期完整摊开:SqlSession是一次数据库对话,commit()之前所有写操作都在事务里没落地,close()释放连接。漏掉commit是最常见的翻车点——界面提示“存款成功”,重启程序余额没变,因为事务回滚了。

<update id="addMoneyById"> update tb_user set money = money + #{money} where id = #{id} </update> <update id="minusMoneyById"> update tb_user set money = money - #{money} where id = #{id} </update>

这两条 SQL 用的是“在数据库端做增减”而不是“查出来加完再写回去”。前者是原子操作,后者在并发下会丢更新。#{money}是预编译占位符,MyBatis 会把它转成?并绑定参数,天然防 SQL 注入;如果写成${money}就是字符串拼接,输入框里敲个1; drop table就热闹了。这个区别是安全审计的必考点。

4.2 为什么每次操作都新开会话

代码里每个 Service 方法都openSession一次、close一次,没有复用。教学项目这么写没毛病:逻辑简单、不会出现会话泄漏、每次操作都是独立事务。但真实系统里频繁开关连接开销大,通常用连接池加会话复用。理解这一点,你就明白“连接池”不是玄学,它解决的就是“开一次会话成本太高”的问题。参数上,openSession()默认不自动提交,所以必须显式commit();如果改成openSession(true),每条语句自动提交,反而失去了“多条语句一个事务”的能力。

4.3 转账缺失的那一半

功能列表里写了转账,但正文的代码只给了存取款。从机制上推,转账必然是“扣一方、加一方”两条 update,且必须包在同一个SqlSession里:先扣款、再入账、一起commit。如果分两个会话各自提交,扣款成功而入账失败,钱就凭空消失了。这正是事务 ACID 里“原子性”要解决的问题,也是课程设计从“能跑”迈向“可信”的分水岭。常见的实现思路是把两条 mapper 调用放进同一个 session,任何一步抛异常就rollback()。

5. 避坑与排查:课程设计里最容易翻车的五个点

5.1 提示成功但余额没变

现象:点“存入”弹出“存款成功”,回菜单查询余额还是原值。原因:SqlSession忘了commit(),或者commit之后又开了新会话查询。解决:确认写操作后紧跟commit(),查询用同一份已提交的数据;调试时在commit前后各打一条日志,看事务边界到底在哪。

5.2 输入“100.5”被拒但提示词不对

现象:输入带小数的金额,弹出“含有非法字符”而不是“只支持百元大钞”。原因:isStrictlyNumeric把小数点也当非法字符挡掉了,校验顺序导致提示不精确。解决:明确业务规则——ATM 只收整百,那小数点本就该拒,但提示语应改成“请输入整百金额”;若允许角分,则校验函数要放行小数点并改用BigDecimal解析。

5.3 取款后余额变成负数

现象:连续快速点击取款按钮,余额被扣成负数。原因:余额检查和扣减是两条语句,中间没有原子约束;或者按钮未禁用导致重复提交。解决:SQL 改成update tb_user set money = money - #{money} where id = #{id} and money >= #{money},用受影响行数判断是否成功;界面层在请求返回前禁用按钮。

5.4 改密后旧密码还能登录

现象:修改密码提示成功,退出后用旧密码仍能进。原因:改密只更新了内存对象没落库,或更新语句的where条件写错导致更新了 0 行却返回成功。解决:检查update的受影响行数,为 0 就提示“原密码错误”;确认commit执行。

5.5 背景图在打包后消失

现象:IDE 里背景正常,打成 jar 后一片空白。原因:用getClass().getResource("/images/xxx.gif")读资源,路径大小写或打包配置不对,返回 null 后Objects.requireNonNull直接抛异常。解决:确认资源在src/main/resources下、路径大小写一致;对getResource的返回值做空判断,缺图时降级为纯色背景而不是崩溃。

6. 从“能跑”到“可信”:一个课程设计该留什么后手

这套代码最值得琢磨的地方,不是它写得多好,而是它把“教学项目”和“生产系统”的鸿沟标得清清楚楚。界面层用绝对定位、服务层用字符串当模式开关、持久层手动管会话——每一条在课程设计里都合理,因为目标是“用最小认知成本把闭环跑通”。但只要你多想一步,就能看到每条捷径背后的代价:绝对定位换不来自适应,字符串开关换不来编译期检查,手动会话换不来连接复用。

我的判断是,这类项目的正确用法不是照抄,而是拿它当“反例基线”。比如把type.equals("存入")改成枚举OperationType.DEPOSIT,编译器立刻帮你挡住拼写错误;把余额检查挪进 SQL 的where条件,并发透支问题当场消失;把openSession包进 try-with-resources,会话泄漏不再靠自觉。这些改动都不大,但每改一处,你对“为什么框架要这么设计”的理解就深一层。

教学写法生产等价物解决的核心问题
字符串当模式开关枚举 / 策略模式编译期校验、可扩展
先查后改条件更新 + 受影响行数并发下的原子性
手动开关会话连接池 + 声明式事务资源复用、事务边界清晰
double 判整百BigDecimal 全程金额精度
绝对定位布局管理器界面自适应

验证方法也很朴素:写一个并发测试,两个线程同时对同一账户各取 80 元、余额 100 元,看最终余额是 -60 还是 20。跑一次你就永远记住“检查与扣减必须原子”。再写一个精度测试,连续存 0.1 元一百次(假设放开角分),看余额是不是 10.00 而不是 9.999999。这两个测试比任何理论都管用。

从那以后我每次碰金额相关的代码,都强制走一遍“字符串入参 → BigDecimal 解析 → 条件更新 → 查受影响行数”这条链,少一步心里就不踏实。这份 ATM 课程设计资源把这条链的雏形完整摆在你面前,界面、校验、SQL、事务一个不缺,正好拿来当练手的底稿——把上面那几处“教学妥协”逐个替换掉,你对 Java 分层和金融业务底层的理解,会比读十篇教程都扎实。希望帮到你。

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

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

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

立即咨询