简介:这份资源是面向高校Java课程设计场景的ATM柜员机模拟系统完整项目文档,适合正在学习Java面向对象编程、需要完成课程设计或实训作业的学生参考。文档围绕用户登录、账户查询、存款、取款、更改密码等核心功能展开,涵盖需求分析、功能图设计、界面布局、取款存款代码实现、逻辑校验与SQL语句操作等内容,并附有项目总结与参考文献,可帮助读者理清从需求到编码的完整开发思路。压缩包共1个docx文件,大小约1.21MB,结构完整、便于直接查阅与借鉴。目前已有387人学习下载,适合需要快速搭建课程设计框架、对照功能模块查漏补缺的Java初学者使用。
1. 一个课程设计里的 ATM,为什么值得当成“事务系统”来读
很多人看到“ATM 柜员机模拟系统”第一反应是:不就是个 Swing 界面加几个按钮,点一下余额加加减减吗?但真正把这份课程设计源码翻进去,你会发现它踩中了几乎所有业务系统的核心命题——登录态怎么维持、金额怎么校验、余额扣减怎么保证不出现负数、界面切换和业务逻辑怎么解耦。它表面是个 Java 课设,骨子里是一套缩微版的账户交易系统。
这份资源的价值不在于界面多漂亮,而在于它把“从需求分析到 SQL 落库”的完整链路摊开给你看:登录验证、余额查询、存取款、改密、转账,每个功能背后都有对应的校验逻辑和持久化动作。搞懂它,你就搞懂了为什么业务代码里那些看似啰嗦的判断,一条都不能省。
2. 登录与窗口切换:CardLayout 为什么成了 GUI 的“状态机”
2.1 一个主窗口装下所有界面,靠的是什么
这份课设的界面结构很典型:一个主窗口,里面塞了登录、菜单、查询、存取款、转账、改密等多个面板,用户点按钮在这些面板之间跳。实现这套切换的核心是 Swing 的CardLayout——你可以把它理解成一摞卡片,同一时刻只有最上面那张可见,show(container, "菜单")就是把指定名字的卡片翻到最前面。
为什么不用“每个功能开一个新 JFrame”?因为多窗口会带来一堆麻烦:窗口层级管理、关闭顺序、登录态在窗口之间传递、用户误关某个窗口导致程序半死。CardLayout把所有界面收进一个容器,切换只是换显示,主窗口的生命周期不变,登录用户的信息(比如mainView.currentId)也就天然地挂在这个主视图上,各个功能面板通过构造函数拿到mainView引用就能读到当前登录人。
常见的实现思路是:主类继承JFrame,contentPane设成CardLayout,每个功能面板是一个JPanel子类,构造时把Container contentPane、CardLayout cardLayout、MainView mainView三个引用传进去。这样面板既能自己触发切换(cardLayout.show),又能读到全局状态(mainView.currentId),还能弹对话框(父组件传mainView)。这套“依赖注入”虽然土,但在小系统里非常直接。
2.2 登录态为什么不能只存在某个面板里
这里有个容易被忽略的设计点:登录成功后,当前用户的 ID 存在哪?如果存在登录面板自己的字段里,那用户切到取款面板时,取款面板根本不知道是谁在操作。所以课设把currentId放在MainView上,所有功能面板共享同一个主视图引用。
这其实就是最小化的“会话”概念。真实系统里这个会话会放在HttpSession或 Token 里,课设里放在主窗口对象上,本质一样:登录态必须比任何一个功能界面活得久。你后面看到的handleService.addMoney(mainView.currentId, ...),第一个参数就是从这个共享状态里取的。如果哪天你发现取款扣错了人的钱,第一件事就该去查这个currentId是在哪一步被赋值、有没有可能被别的登录覆盖。
提示:
CardLayout只负责“显示哪张卡”,不负责“这张卡里的数据是否合法”。界面切换和业务校验是两件事,混在一起写就是后面所有 bug 的源头。
3. 存取款的校验链:为什么“能不能取”和“取多少”必须分开判
3.1 从输入框到数据库,中间隔了四道关
看这份课设的addMoney和minusMoney,校验顺序非常讲究,不是随手写的。以取款为例,代码大致是这样一条链:
@Override public Result<Boolean> minusMoney(Long id, String money) { // 第一关:空值。输入框什么都没填就点确认,这里必须拦住 if (money.equals("")){ return new Result<Boolean>("不能为空", false); } // 第二关:字符合法性。防止用户输入 "abc" 或 "1e5" 这类东西 if(!StringUtils.isStrictlyNumeric(money)){ return new Result<Boolean>("含有非法字符", false); } // 第三关:面额约束。ATM 只出百元钞,这是业务规则不是技术限制 if(Double.parseDouble(money) % 100 != 0){ return new Result<Boolean>("ATM 中只支持百元大钞,零钱请前往柜台领取", false); } // 第四关:余额是否够。注意这里用的是 compareTo 而不是直接减 SqlSession sqlSession = sqlSessionFactory.openSession(); MysqlOperateMapper userMapper = sqlSession.getMapper(MysqlOperateMapper.class); User user = userMapper.selectById(id); if(user.getMoney().compareTo(new BigDecimal(money)) < 0){ return new Result<Boolean>("余额不足", false); } userMapper.minusMoneyById(id, new BigDecimal(money)); sqlSession.commit(); sqlSession.close(); return new Result<Boolean>("取款成功", true); }这四关的顺序不是随便排的。空值和字符校验放在最前面,是因为它们不碰数据库,纯内存判断,成本最低,能挡掉大部分脏输入。面额校验放在余额查询之前,是因为“不是整百”这个错误跟账户里有多少钱无关,先判掉可以少一次数据库往返。余额判断放在最后,因为它必须读库,是整条链里最贵的一步。
3.2 为什么余额比较要用 compareTo 而不是减法
这是这份代码里最值得细看的一处。判断余额够不够,直觉写法是if (user.getMoney() - amount < 0),但代码用的是user.getMoney().compareTo(new BigDecimal(money)) < 0。原因有两层:
第一层是精度。金额如果用double存,0.1 + 0.2 != 0.3这种经典问题会让“余额刚好等于取款额”的判断出现玄学偏差。课设里余额字段用的是BigDecimal,比较大小必须走compareTo,因为BigDecimal的equals会把精度(scale)也算进去,100和100.00用equals比是不相等的,但compareTo认为相等。用错方法,就会出现“明明余额够却提示不足”的翻车现场。
第二层是语义。compareTo返回负数表示“小于”,返回 0 表示“相等”,返回正数表示“大于”。代码注释里写得很清楚:a>b 返回值>0, a=b 返回值=0, a<b 返回值小于 0。判断“余额 < 取款额”就是compareTo(...) < 0,读起来比减法直观,也不会引入中间计算结果。
3.3 SQL 层为什么用money = money - #{money}而不是先查后写
对应的 Mapper 语句是:
<update id="minusMoneyById"> update tb_user set money = money - #{money} where id = #{id} </update> <update id="addMoneyById"> update tb_user set money = money + #{money} where id = #{id} </update>注意这里是在数据库里做加减,而不是“先 select 出余额,在 Java 里算好新值,再 update 回去”。这两种写法在单用户场景下结果一样,但语义差别很大:前者把“读-改-写”压缩成一条原子语句,数据库在执行这条 update 时会对该行加锁,天然避免了并发下的丢失更新。后者则是典型的“读-改-写”三步,两个请求同时进来,都读到 1000,都减 100,最后余额可能变成 900 而不是 800。
课设本身是单机单用户,并发问题不明显,但这个写法习惯是对的。常见的实现思路是:能用一条 SQL 表达的计算,就不要拆成“查出来再算再写回去”。当然,严格来说这份代码的余额校验(先selectById再判断)和扣减(minusMoneyById)之间仍然有窗口期,真实系统要么把校验也塞进 SQL 的where条件里(update ... where id=? and money>=?,看影响行数判断成败),要么用事务加行锁。课设没做到这一步,但方向是对的。
4. 数据持久化与事务边界:SqlSession 手动开关背后的取舍
4.1 为什么每个方法都自己 open、commit、close
看addMoney和minusMoney,每个业务方法里都是这套固定动作:openSession→getMapper→ 执行 →commit→close。这是 MyBatis 最原始的用法,没有 Spring 帮你管事务,一切手动。
这种写法在课设里是合理的,因为它把“事务边界”暴露得很清楚:一次存款就是一个事务,从打开会话到提交,中间的任何异常都应该导致不提交。但代码里有个隐患——如果userMapper.addMoneyById抛异常,后面的commit和close都不会执行,SqlSession就泄漏了。正确的骨架应该是try/finally,在finally里close,异常时rollback。
我一般会这么理解:这份代码是“教学版事务”,它让你看见事务的五个动作(开、取、执行、提交、关),但省略了异常路径的清理。你在自己项目里照抄这个结构,一定要补上try/catch/finally,否则连接池跑一段时间就耗尽,表现为“用着用着就卡死”,这种问题排查起来非常费劲。
4.2 转账功能暴露的原子性问题
课设功能列表里有转账,但正文只给了存取款的 SQL。转账的本质是“一个账户减、另一个账户加”,这两步必须在同一个事务里,要么都成功,要么都回滚。如果按存取款的写法,减和加各自openSession、各自commit,那减成功、加失败的时候,钱就凭空消失了。
正确的做法是:一个SqlSession里连续执行两条 update,最后统一commit。这样两条语句共享同一个事务,任何一条失败都可以整体回滚。这也是为什么事务边界应该按“业务操作”划,而不是按“单条 SQL”划。存取款是单表单行操作,一个事务一条语句没问题;转账是跨行操作,事务必须包住两条语句。
注意:判断事务边界的一个简单标准是——如果这个操作做到一半程序崩了,数据会不会自相矛盾?会,就必须放进同一个事务。
4.3 密码存储为什么不能明文
需求分析里明确写了“对用户密码的加密存储和传输”。课设正文没展开加密实现,但从设计动机上讲,密码落库前必须做不可逆哈希(比如加盐的 SHA-256 或 bcrypt),登录时把用户输入哈希后跟库里比对,而不是把库里的密码解密出来比。明文存密码的系统,一旦数据库被拖走,所有用户的密码直接暴露,而且用户往往多平台复用密码,影响面远超这一个系统。
课设阶段做到“哈希后存储”就已经比大多数同学强了。常见的实现思路是:注册/改密时hash(password + salt)存库,登录时用同样的 salt 再算一次比对。salt 每个用户独立随机生成,跟哈希一起存。这样即使两个用户密码相同,库里的哈希值也不同,彩虹表打不动。
5. 避坑与排查:课设代码里那些一跑就现原形的问题
5.1 现象:输入框留空点确认,程序直接抛异常
原因:Double.parseDouble("")会抛NumberFormatException。课设的addMoney里先判了money.equals("")再解析,顺序是对的;但如果你自己写的时候把解析放在判空前面,空输入就会让整个事件处理线程崩掉,界面看起来“点了没反应”。
解决:所有从界面拿到的字符串,进业务层第一件事就是判空和判格式,解析动作永远放在校验之后。StringUtils.isStrictlyNumeric这类工具方法就是干这个的,别嫌它啰嗦。
5.2 现象:余额明明够,取款却提示“余额不足”
原因:多半是金额类型用错。如果余额在 Java 里是double,取款额也是double,1000.0 - 999.99可能算出0.010000000000000009这种值,再跟 0 比就出偏差。或者用了BigDecimal.equals而不是compareTo,100和100.00被判为不等。
解决:金额统一用BigDecimal,比较一律compareTo,构造BigDecimal时用字符串构造器new BigDecimal(money)而不是new BigDecimal(double),后者会把 double 的误差原样带进来。
5.3 现象:改完密码后,旧密码还能登录
原因:改密操作只更新了界面上的某个变量,没有真正update到数据库;或者 update 了但没commit,会话关闭时改动被丢弃。MyBatis 手动管理会话时,忘记commit是高频翻车点,因为不提交也不报错,程序看起来一切正常。
解决:改密、存取款这类写操作,执行完 update 必须显式commit。排查时可以在 update 后立刻再select一次,看读出来的是不是新值,能快速定位是“没写进去”还是“写了没提交”。
5.4 现象:连续点两次“取款”,扣了两次钱
原因:按钮的ActionListener没有防重入,用户手快连点两下,两次事件都进了业务方法,各自读余额、各自扣减。单机场景下两次执行是串行的,但如果第一次扣完余额已经不足,第二次应该被余额校验拦住;真正危险的是两次都通过校验的边界情况,或者并发场景。
解决:简单做法是点击后立刻enterButton.setEnabled(false),业务处理完再恢复;更稳的做法是把余额校验和扣减合并成一条带条件的 SQL(where money >= ?),用影响行数判断是否成功,从数据库层面杜绝超扣。
5.5 现象:程序跑一段时间后卡住,数据库连接拿不到
原因:SqlSession在异常路径上没有close,连接泄漏。课设代码里openSession之后如果中间抛异常,close不会执行,连接池里的连接被一个个占满,最后openSession阻塞。
解决:所有SqlSession的使用都套try/finally,finally里close;需要回滚的场景在catch里rollback。这是血泪经验,连接泄漏的表现往往不是立刻报错,而是“跑一阵子才出问题”,最难查。
6. 从课设到可用系统:校验该放在哪一层,是个架构取舍
把这份 ATM 课设读完,最值得琢磨的不是某个 API 怎么用,而是校验逻辑的分布。你会看到校验散落在三个地方:界面层判空、业务层判格式和面额、数据库层用where兜底余额。这种“多层设防”不是冗余,而是每一层能挡住的东西不一样。
界面层能挡的是“用户根本没输入”,它离用户最近,反馈最快,但它不可信,因为请求可以绕过界面直接打到业务层。业务层能挡的是“业务规则”,比如整百、余额充足,它是系统的守门人,但它依赖读出来的数据是最新的。数据库层的条件更新是最后一道闸,它利用行锁保证原子性,是并发场景下唯一真正可靠的一层。
这三层的取舍在于:校验放得越靠前,用户体验越好、数据库压力越小;放得越靠后,正确性越有保障。课设的做法是“前面尽量挡,后面留一手”,这个思路放到任何业务系统都成立。我见过太多项目把余额校验只写在 Java 里,结果并发一上来就超扣,最后不得不回头补一条带条件的 update。
还有一个取舍是界面和逻辑的耦合度。课设里功能面板直接new HandleServiceImpl()调业务方法,简单直接,但面板和实现类绑死了,想换实现或加一层缓存就得改界面代码。真实项目会用接口加依赖注入把这两层隔开,代价是多一层抽象、多一点配置。课设规模小,直接 new 没问题;但如果你打算把这个项目继续做大,第一件事就该是把HandleService抽成接口,让面板只依赖接口。
验证这套设计是否站得住,有个很朴素的方法:把界面全部删掉,只留业务层和数据库,写个测试类直接调minusMoney,看它能不能正确拒绝空值、非数字、非整百、余额不足这四种输入。如果业务层脱离界面还能独立工作,说明分层是干净的;如果一删界面业务就跑不起来,说明逻辑漏到界面里了。这个“去界面测试”的习惯,我从写完这个课设之后就一直在用,每次新项目先让核心逻辑能脱离 UI 跑通,再往上搭界面,返工次数明显少了。
希望这份拆解帮到你,源码里的校验链和 SQL 写法,值得对着自己动手改一遍。
本文还有配套的精品资源,点击获取