资讯动态

SSM 模拟 ATM 项目实战:从分层架构到转账事务的完整链路

发布时间:2026/10/17 14:33:04 来源:尧图企业网站定制
简介这是一份面向Java Web初学者与SSM框架学习者的实践型教学项目以模拟ATM自动取款机业务为载体帮助开发者理解Spring、SpringMVC与MyBatis三大框架的整合方式。项目围绕用户管理、存取款与转账交易、登录验证、日志记录及异常处理等模块展开覆盖依赖注入、AOP事务、MVC请求分发与MyBatis持久化操作等核心知识点适合作为课程设计或框架入门的练手案例。资源包为rar格式共83个文件约15.98MB其中以39个jar依赖库、16个xml配置文件、6个java源码与6个class文件为主另含5个jsp页面及properties、classpath等工程配置完整保留了Eclipse项目结构与数据库脚本便于直接导入运行与二次修改。目前已有386人学习下载可帮助读者快速掌握SSM集成配置流程与Web应用分层设计思路。1. 从一台“假 ATM”看懂 SSM 的真实分层很多人学完 Spring、SpringMVC、MyBatis 之后单个注解都认识凑一起就不知道谁管谁。这个 SSM 模拟 ATM 项目就是冲着这个痛点来的它把开户、登录、查询余额、存款、取款、转账、改密、交易流水这一整套银行柜面逻辑塞进一个标准的 SSM 三层结构里。你打开它看到的不是零散 Demo而是一条从 Controller 进、Service 转、DAO 落库的完整链路。适合谁刚学完三大框架、想找一个能跑通又能改的业务型练手项目的开发者。它不追求高并发追求的是让你把“一个请求到底经过哪些层”这件事彻底看明白。2. 项目骨架与请求链路一个转账请求到底走了几层2.1 目录结构决定你改代码的速度拿到源码先别急着跑先看目录。SSM 项目的目录结构基本就是它架构的投影看懂了目录后面改功能就知道该动哪个包。这个模拟 ATM 常见做法是标准的 Maven 结构我一般会先确认src/main/java下的包划分src/main/java/com/example/atm/ ├── controller/ # 接收前端请求参数校验返回视图或 JSON │ ├── AccountController.java │ └── TransactionController.java ├── service/ # 业务逻辑转账扣款、余额校验 │ ├── AccountService.java │ └── impl/AccountServiceImpl.java ├── mapper/ # MyBatis 的 DAO 接口 │ ├── AccountMapper.java │ └── TransactionMapper.java ├── entity/ # 和数据库表一一对应的实体 │ ├── Account.java │ └── Transaction.java └── util/ # 结果封装、日期工具 └── Result.java src/main/resources/ ├── mapper/ # MyBatis 的 XML 映射文件 │ ├── AccountMapper.xml │ └── TransactionMapper.xml ├── spring-dao.xml # 数据源 SqlSessionFactory ├── spring-service.xml # 事务 Service 扫描 ├── spring-mvc.xml # 视图解析器 Controller 扫描 └── jdbc.properties # 数据库连接参数这个划分里最关键的一点controller只做参数接收和结果返回service才写业务规则mapper只负责和数据库对话。新手最容易犯的错是把转账的余额判断写进 Controller结果事务加不上扣了钱没加钱。看到service/impl这个包你就知道业务规则应该往哪塞。2.2 三层配置文件的职责边界SSM 最劝退的地方就是三个 XML 各管一摊配错了启动就报NoSuchBeanDefinitionException。我一般按“谁扫描谁”来记spring-dao.xml配数据源、SqlSessionFactory、MapperScannerConfigurer负责把 Mapper 接口变成可注入的 Bean。spring-service.xml开启注解事务、扫描service包负责让Transactional生效。spring-mvc.xml扫描controller包、配视图解析器负责把请求映射到方法。!-- spring-dao.xml 关键片段 -- bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- 自动扫描 mapper 目录下的 XML -- property namemapperLocations valueclasspath:mapper/*.xml/ /bean !-- 扫描 mapper 接口生成代理实现 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.atm.mapper/ /bean参数说明driverClassName和url从jdbc.properties读换数据库只改属性文件mapperLocations用通配符新增 XML 不用改配置basePackage决定哪些接口会被 MyBatis 接管。这里踩坑最多的是MapperScannerConfigurer和context:component-scan扫到同一个包导致 Mapper 被实例化两次启动直接报冲突。2.3 一个转账请求的完整链路把链路串起来看比背概念有用。用户在页面点“转账”请求打到AccountController.transfer()Controller RequestMapping(/account) public class AccountController { Autowired private AccountService accountService; PostMapping(/transfer) ResponseBody public Result transfer(RequestParam String fromCard, RequestParam String toCard, RequestParam BigDecimal amount) { // Controller 只做参数透传业务规则全部下沉到 Service accountService.transfer(fromCard, toCard, amount); return Result.success(转账成功); } }逻辑说明RequestParam接收表单参数ResponseBody让返回值走 JSON 而不是视图解析。参数fromCard、toCard是卡号amount用BigDecimal而不是double这是金额计算的硬规矩用double迟早出现0.1 0.2的玄学误差。Service 层才是真正干活的地方Service public class AccountServiceImpl implements AccountService { Autowired private AccountMapper accountMapper; Autowired private TransactionMapper transactionMapper; Override Transactional(rollbackFor Exception.class) public void transfer(String fromCard, String toCard, BigDecimal amount) { Account from accountMapper.selectByCard(fromCard); Account to accountMapper.selectByCard(toCard); if (from null || to null) { throw new RuntimeException(卡号不存在); } if (from.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } // 先扣后加两步都在同一个事务里 accountMapper.updateBalance(fromCard, amount.negate()); accountMapper.updateBalance(toCard, amount); // 记录流水方便对账 transactionMapper.insert(new Transaction(fromCard, toCard, amount, new Date())); } }逻辑说明Transactional(rollbackFor Exception.class)是关键默认只回滚RuntimeException加上rollbackFor才能覆盖受检异常。扣款用amount.negate()把正数变负数一条 SQL 同时支持加减减少方法数量。流水插入放在最后一旦前面任何一步抛异常整个方法回滚不会出现“钱扣了流水没记”的黑匣子状态。Mapper 层就是纯 SQLupdate idupdateBalance UPDATE account SET balance balance #{delta} WHERE card_no #{cardNo} /update参数说明#{delta}是变动金额正数加、负数减#{cardNo}是卡号。用#{}而不是${}前者是预编译占位符能防注入后者是字符串拼接卡号里带个引号就能把表删了。3. 环境搭建与跑通从空库到能登录3.1 数据库建表与初始数据项目跑不起来八成是库没建对。先建库建表字段名要和实体、XML 里的列名对得上CREATE DATABASE atm_db DEFAULT CHARACTER SET utf8mb4; USE atm_db; CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, balance DECIMAL(15,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_card VARCHAR(20), to_card VARCHAR(20), amount DECIMAL(15,2) NOT NULL, create_time DATETIME NOT NULL ); INSERT INTO account(card_no, password, balance) VALUES (6222001, 123456, 1000.00), (6222002, 123456, 500.00);参数说明balance用DECIMAL(15,2)别用FLOAT金额精度不能丢card_no加UNIQUE约束防止重复开户status字段给账户冻结留了扩展位。初始两条数据方便你直接登录测试密码这里存的是明文真实项目必须加盐哈希练手阶段先跑通再说。3.2 连接配置与启动顺序jdbc.properties里改成本机数据库信息jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/atm_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password你的密码参数说明serverTimezone不写MySQL 8 会报时区错误characterEncodingutf8保证中文不乱码。启动顺序上Tomcat 加载web.xml里面用ContextLoaderListener加载spring-dao.xml和spring-service.xml再用DispatcherServlet加载spring-mvc.xml。父容器管 Service 和 DAO子容器管 Controller子容器能拿到父容器的 Bean反过来不行——这就是为什么 Controller 能注入 Service而 Service 注入不了 Controller。3.3 用接口验证链路是否打通启动后别急着点页面先用接口验证。登录接口curl -X POST http://localhost:8080/atm/account/login \ -d cardNo6222001password123456返回{code:200,msg:登录成功}说明 Controller、Service、Mapper 三层都通了。如果返回 500看控制台堆栈Invalid bound statement是 XML 的namespace或id和接口对不上Access denied是数据库账号密码错Table doesnt exist是库名或表名写错。转账接口同理传fromCard、toCard、amount三个参数成功后查transaction表应该有流水记录。4. 核心模块拆解登录、存取款与流水记录4.1 登录校验与密码处理登录看着简单但它是理解参数传递的好入口Override public Account login(String cardNo, String password) { Account account accountMapper.selectByCard(cardNo); if (account null) { throw new RuntimeException(卡号不存在); } if (account.getStatus() 0) { throw new RuntimeException(账户已冻结); } // 练手项目用明文比对生产环境应换成 BCrypt 校验 if (!password.equals(account.getPassword())) { throw new RuntimeException(密码错误); } return account; }逻辑说明先查卡号再比密码顺序不能反否则卡号不存在和密码错误返回同一个提示用户没法判断问题。status判断给冻结账户留了口子。密码这块是练手项目的通病明文存储二次开发时第一件事就是换成BCryptPasswordEncoder加盐哈希后即使库被拖走也解不出原文。4.2 存款取款的余额更新存款和取款本质是同一个更新操作只是方向不同Override Transactional(rollbackFor Exception.class) public void deposit(String cardNo, BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) 0) { throw new RuntimeException(金额必须大于零); } accountMapper.updateBalance(cardNo, amount); transactionMapper.insert(new Transaction(null, cardNo, amount, new Date())); } Override Transactional(rollbackFor Exception.class) public void withdraw(String cardNo, BigDecimal amount) { Account account accountMapper.selectByCard(cardNo); if (account.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } accountMapper.updateBalance(cardNo, amount.negate()); transactionMapper.insert(new Transaction(cardNo, null, amount, new Date())); }逻辑说明存款金额必须为正取款要先查余额再扣。compareTo是BigDecimal比较大小的正确姿势用equals会因为精度不同返回 false这是血泪经验。流水记录里存款的from_card为 null取款的to_card为 null查询时按方向过滤即可。4.3 交易流水的分页查询流水多了不能一次全查出来分页是必备技能select idselectByCardWithPage resultTypecom.example.atm.entity.Transaction SELECT id, from_card, to_card, amount, create_time FROM transaction WHERE from_card #{cardNo} OR to_card #{cardNo} ORDER BY create_time DESC LIMIT #{offset}, #{limit} /select参数说明offset是起始行limit是每页条数由 Service 层根据页码算出来。ORDER BY create_time DESC保证最新的流水在最前面。这里注意OR条件会让索引失效数据量大时常见做法是拆成两条查询再合并或者给from_card、to_card分别建索引。5. 避坑与排查启动报错和事务失效的常见原因5.1 现象启动报 NoSuchBeanDefinitionException原因spring-mvc.xml里component-scan扫了controller包但spring-service.xml没扫service包或者两个配置文件根本没被web.xml加载。解决检查web.xml里ContextLoaderListener的contextConfigLocation是否包含spring-*.xml再确认每个 XML 的base-package覆盖了对应包。5.2 现象转账扣了钱没加钱原因Transactional没生效。常见于把注解加在 Controller 上或者 Service 类没被 Spring 管理自己new出来的或者异常被try-catch吞了没抛出去。解决注解加在 Service 实现类的方法上确保类被Service标注且由容器注入异常要往外抛别在方法里catch完什么都不做。5.3 现象MyBatis 报 Invalid bound statement原因Mapper 接口的方法名和 XML 里的id对不上或者 XML 的namespace写错了类路径或者mapperLocations没扫到 XML。解决namespace必须是接口全限定名id必须和方法名一字不差XML 放在resources/mapper下且被mapperLocations通配符覆盖。5.4 现象中文乱码原因数据库字符集、连接 URL 编码、页面编码三处不一致。解决建库用utf8mb4URL 加characterEncodingutf8JSP 页面顶部加% page contentTypetext/html;charsetUTF-8 %三处统一。5.5 现象金额出现 0.30000000000000004原因用了double或float做金额运算。解决实体、参数、SQL 全部换成BigDecimal比较用compareTo运算用add、subtract别用、-。6. 二次开发把模拟 ATM 改成能用的账户系统跑通之后这个项目最大的价值是当骨架改。我一般按三个方向动手。第一是安全加固把明文密码换成 BCrypt登录失败次数限制加上卡号做脱敏返回。第二是功能扩展加一个transfer_limit字段做单笔限额加daily_limit做日累计限额在 Service 的转账方法里先查当日流水总额再判断。第三是接口化把 JSP 换成前后端分离Controller 全部返回 JSON用RestController替代Controller前端用任意框架对接。改造时验证方法很直接每改一个功能先写一条对应的 SQL 确认数据变化再调接口确认返回最后看流水表有没有记录。三步都对才算改完。我踩过最深的坑是改事务传播行为把transfer的Transactional改成REQUIRES_NEW结果内层方法独立提交外层回滚时内层已经落库对账直接对不上。从那以后我每次动事务注解都强制走一遍“扣款成功但抛异常”的用例确认两边余额都没变才敢提交代码。希望这个项目能帮你把 SSM 的分层真正跑通而不是停留在背注解上。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑