SSM模拟ATM项目实战:从环境搭建到事务与安全细节全解析
简介这是一套SSM模拟ATM系统项目基于Spring、SpringMVC与MyBatis三大框架整合而成面向Java Web学习者与需要练习分层架构的中级开发者。项目以银行自动取款机作为核心业务场景完整覆盖用户登录、余额查询、存款、取款、转账等常用功能同时在代码中体现依赖注入、面向切面、请求分发与SQL映射等关键机制有助于直观理解这三个框架如何协同工作。压缩包内含83个文件总大小约15.98MB主要包含39个依赖包、16个XML配置文件、6个Java源码、6个Class字节码、5个JSP页面以及少量属性文件和工程描述文件目录结构完整可直接导入开发工具查看运行。当前已有386人学习使用说明该案例具有一定参考价值。借助这个项目读者可以系统地梳理SSM整合步骤与各层代码调用关系掌握MyBatis映射文件写法、Spring事务配置、前端页面与Controller交互的常见方式也能为毕业设计或实训项目提供可扩展的基础原型是一份适合边看边练的整合实践资料。1. SSM模拟ATM把Spring、SpringMVC、MyBatis三件套在一个业务流程里跑通SSM模拟ATM这个项目看起来只是把Spring、SpringMVC、MyBatis拼在一起做一个取款机Demo但真做完你会发现SSM的整合细节、事务边界、Session管理和金额精度问题全藏在里面。项目以ATM存取款、转账、查流水为主体用Spring管理业务对象SpringMVC处理请求分发MyBatis负责SQL持久化适合刚把三个框架分开学完、想找一个小而完整的项目练手的人。它能解决的实际问题很具体你单独学过Spring IoC、SpringMVC请求映射、MyBatis映射规则但三者凑到一起时总在配置层翻车——Mapper扫描不到、事务没生效、过滤器顺序错乱。这个项目把这些坑集中暴露在一次ATM业务流里照着跑通一遍基本等于把SSM整合的常见问题都提前踩了一遍。新手能顺着代码理解分层关系熟手也能在Session管理、BigDecimal金额处理和事务边界上看出设计取舍。2. SSM环境搭建先让“查询余额”这个最小流程跑通搭SSM模拟ATM时的第一件事不是写业务而是把一个“查询余额”的接口跑通。因为Spring容器、MyBatis会话工厂、SpringMVC的DispatcherServlet三者连接正确后后面所有业务代码都只是往这个骨架上添肉。查询余额的路径最短Controller接收请求Service调用MapperMapper返回实体再渲染到页面。这个链路通了说明框架整合没问题。2.1 依赖清单Spring 5.3 与 MyBatis 3.5 的成熟组合先看Maven依赖。常见做法是选择Spring 5.3.x配合MyBatis 3.5.x再加上mybatis-spring 2.0.x。Spring 5.3依然基于javax.servlet和Tomcat 8/9可以直接配合如果把版本升到Spring 6.xServlet命名空间会换成jakarta项目里web.xml和很多依赖都要跟着改对模拟ATM这个体量的Demo来说不值得。properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target spring.version5.3.31/spring.version mybatis.version3.5.16/mybatis.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies这里几个依赖需要说明spring-jdbc不是给SpringMVC用的而是给mybatis-spring提供事务管理支持否则DataSourceTransactionManager没法创建。mysql-connector-j选8.0.33是因为MySQL 8的连接串上要求带时区参数驱动版本太老会直接报时区错误。jstl是JSP页面的标签库ATM页面里做金额展示和错误提示会用到。2.2 web.xml先让Spring容器和SpringMVC入口就位SSM不是Spring Boot那种自动装载所有东西都要通过web.xml告诉Servlet容器“Spring根容器怎么启动DispatcherServlet去哪找配置”。下面这段配置我建议直接作为基础模板。?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-dao.xml,classpath:spring/spring-service.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping /web-appCharacterEncodingFilter必须放在配置的最前面否则Tomcat在处理请求参数时已经按默认ISO-8859-1解析过一次后面再换编码也改变不了已读取的参数。forceEncodingtrue表示不管请求还是响应都强制使用UTF-8。DispatcherServlet的url-pattern用“/”而不是“*.do”SpringMVC会直接接管所有请求Controller写路由时不需要额外加后缀。2.3 Mapper接口与XML映射的目录约定这里有个容易被忽略的硬规则如果使用MapperScannerConfigurer扫描com.example.mapper包那么Mapper.xml文件必须放在src/main/resources/com/example/mapper目录下并且namespace要写接口的全限定名否则MyBatis不认为这个XML和接口有关系。public interface AccountMapper { Account selectByCardNo(Param(cardNo) String cardNo); }mapper namespacecom.example.mapper.AccountMapper select idselectByCardNo parameterTypestring resultTypecom.example.entity.Account select id, card_no , balance from account where card_no #{cardNo} /select /mapperresultType直接写实体类全限定名MyBatis会把“card_no”自动映射成“cardNo”前提是在mybatis-config.xml里开启mapUnderscoreToCamelCasetrue。如果没开启就得手动写resultMap否则运行后会看到属性全是null。在spring-dao.xml里把Mapper接口扫描和SqlSessionFactory串起来bean iddataSource classorg.apache.commons.dbcp2.BasicDataSource destroy-methodclose 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/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.mapper/ /beanmapperLocations显式告诉MyBatis去哪里找XML这样即使XML目录和接口包路径不一致也可以统一用通配符兜底。数据源我建议在dbcp2和druid之间二选一模拟项目用dbcp2足够真实项目如果想看SQL监控可以换druid但配置会多几行。3. 账户与事务模拟ATM取款、存款、转账的SSM实现框架通了后面就是业务。ATM最核心的流程是三条取款、存款、转账。这三条都绕不开一个共同问题——事务边界。一次取款至少涉及余额判断、余额更新、流水插入三步任何一步失败都不能让前一步落库。下面按数据表、Service、Controller三层拆开写。3.1 建表与实体准备先建两张表一张account存账户和余额一张trade_flow存每一笔流水。CREATE TABLE account ( id INT AUTO_INCREMENT PRIMARY KEY, card_no VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE trade_flow ( id INT AUTO_INCREMENT PRIMARY KEY, card_no VARCHAR(32) NOT NULL, trade_type VARCHAR(16) NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO account (card_no, password, balance) VALUES (6222000001, e10adc3949ba59abbe56e057f20f883e, 5000.00); INSERT INTO account (card_no, password, balance) VALUES (6222000002, e10adc3949ba59abbe56e057f20f883e, 2000.00);balance字段用DECIMAL(10,2)而不是float或double这是ATM项目第一个必须坚持的地方。password字段里存的是一串MD5值对应123456演示项目可以这样做生产环境必须换BCrypt一类算法后面会展开说。3.2 Service事务边界把余额扣减和流水记录放一起一个常见的错误做法是在Controller里先调Service扣款再调另一个Service插流水。这样两个方法各走一个事务一旦插入流水失败余额已经提交对账时怎么都查不出这笔钱去哪了。正确做法是把扣款、加款、登记流水都放在同一个Service方法里用一个Transactional框住。Service public class AccountService { Autowired private AccountMapper accountMapper; Autowired private TradeFlowMapper tradeFlowMapper; public BigDecimal queryBalance(String cardNo) { Account account accountMapper.selectByCardNo(cardNo); if (account null) { throw new IllegalArgumentException(账户不存在); } return account.getBalance(); } Transactional(rollbackFor Exception.class) public void withdraw(String cardNo, BigDecimal amount) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(取款金额必须大于0); } Account account accountMapper.selectByCardNoForUpdate(cardNo); if (account null) { throw new IllegalArgumentException(账户不存在); } if (account.getBalance().compareTo(amount) 0) { throw new IllegalArgumentException(余额不足); } accountMapper.updateBalance(cardNo, account.getBalance().subtract(amount)); tradeFlowMapper.insertFlow(cardNo, WITHDRAW, amount); } Transactional(rollbackFor Exception.class) public void transfer(String fromCard, String toCard, BigDecimal amount) { Account from accountMapper.selectByCardNoForUpdate(fromCard); if (from null) { throw new IllegalArgumentException(转出账户不存在); } if (from.getBalance().compareTo(amount) 0) { throw new IllegalArgumentException(余额不足); } Account to accountMapper.selectByCardNoForUpdate(toCard); if (to null) { throw new IllegalArgumentException(转入账户不存在); } accountMapper.updateBalance(fromCard, from.getBalance().subtract(amount)); accountMapper.updateBalance(toCard, to.getBalance().add(amount)); tradeFlowMapper.insertFlow(fromCard, TRANSFER_OUT, amount); tradeFlowMapper.insertFlow(toCard, TRANSFER_IN, amount); } }这里有两个关键点。第一Transactional(rollbackFor Exception.class)比默认写法更稳因为默认只回滚RuntimeException如果将来某一步抛出受检异常如SQLException业务照样不会回滚。第二selectByCardNoForUpdate里用了FOR UPDATE这会锁住账户行保证两个并发请求同时取款时不会因为都读到同样的余额而超扣。3.3 Controller层POST重定向避免表单重复提交Service层把业务做完后Controller只做参数接收、会话校验和页面跳转。取款、转账这类操作必须用POST处理成功后redirect到查询页而不是直接forward到模板。否则F5刷新会重新执行上一次的扣款请求。Controller RequestMapping(/atm) public class AtmController { Autowired private AccountService accountService; PostMapping(/withdraw) public String withdraw(HttpSession session, RequestParam(amount) String amountStr, Model model) { Account loginUser (Account) session.getAttribute(loginUser); if (loginUser null) { return redirect:/login; } try { BigDecimal amount new BigDecimal(amountStr).setScale(2, java.math.RoundingMode.HALF_UP); accountService.withdraw(loginUser.getCardNo(), amount); return redirect:/atm/balance; } catch (Exception e) { model.addAttribute(error, e.getMessage()); model.addAttribute(amount, amountStr); return atm/withdraw; } } }amountStr到BigDecimal的转换放在Controller里做会让页面回显更自然。如果用户输入的不是数字String转BigDecimal会抛NumberFormatExceptioncatch住后把error存到Model里页面直接显示具体问题比Servler全局异常更直观。4. 登录与会话SSM模拟ATM里决定成败的细节ATM系统所有操作都必须基于登录用户否则任何人都能查到别人账户。真正做起来时会发现卡号密码校验、Session空值处理、金额精度这三个问题比想象中更影响用户体验。4.1 卡号与密码校验别把明文密码直接落库登录逻辑并不复杂输入卡号和密码Service层查出账户比对密码。只是密码不要用明文也不要简单equals比较项目里即使只是一个模拟也应该养成先哈希再比较的习惯。Service public class AuthService { Autowired private AccountMapper accountMapper; public Account login(String cardNo, String rawPassword) { Account account accountMapper.selectByCardNo(cardNo); if (account null) { return null; } String hashed DigestUtils.md5DigestAsHex(rawPassword.getBytes(StandardCharsets.UTF_8)); if (hashed.equals(account.getPassword())) { return account; } return null; } }这里用MD5主要为了演示模拟项目里也够用。但生产环境密码盐值、BCrypt这些必须安排上否则一旦数据库泄露攻击者用彩虹表就能还原一大片密码。登录成功后别只把cardNo放进Session整个Account对象都放进去方便页面展示当前余额和用户信息。4.2 BigDecimal 与 Double 的精度天坑ATM的金额处理后端必须用BigDecimal。用double做余额会出两个问题二进制浮点误差导致0.10.2变成0.30000000000000004compareTo和equals行为不一致。下面展示一个常见误用。double a 0.1; double b 0.2; System.out.println(a b); // 0.30000000000000004 BigDecimal c new BigDecimal(0.1); BigDecimal d new BigDecimal(0.2); BigDecimal sum c.add(d).setScale(2, java.math.RoundingMode.HALF_UP); System.out.println(sum); // 0.30BigDecimal构造参数一定用字符串不要直接new BigDecimal(0.1)那样会把double的二进制近似值完整带进来。比较金额大于0时用amount.compareTo(BigDecimal.ZERO) 0不能用equals因为支付宝里0.00和0.0在equals角度也不是一回事。每笔入参在Controller层统一setScale(2, RoundingMode.HALF_UP)能避免后面SQL里乘法除法出现太多小数位。4.3 拦截器保护业务URL登录校验可以写在每个Controller方法里但那样太啰嗦。SpringMVC提供了HandlerInterceptor用来统一拦截未登录请求。下面是一个最小实现public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object loginUser session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }在spring-mvc.xml里注册时需要把登录请求和静态资源放行否则会出现“登录页都打不开”的尴尬。mvc:interceptors mvc:interceptor mvc:mapping path/atm/**/ mvc:exclude-mapping path/atm/login/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ bean classcom.example.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-mapping是白名单机制比在拦截器里逐段判断URI干净得多。注意如果页面里用了图片、CSS、JS这些资源路径也要放行否则用户登录后页面样式全丢。5. SSM模拟ATM的五个常见坑现象、原因与解决第2章到第4章把主流程讲完了这里集中写SSM项目里最容易翻车的五个点。每一条都是我实际排查过的场景按现象、原因、解决三段给出。5.1 Invalid bound statement (not found)现象Tomcat启动时不报错一旦调用Mapper接口方法立刻抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。原因接口被MapperScannerConfigurer扫描到了但对应的Mapper.xml没有参与编译或者namespace写错MyBatis找不到SQL语句。常见于XML放在src/main/java目录下Maven默认不会把非resources目录里的XML打进classes。解决把XML移到src/main/resources对应包目录并在spring-dao.xml里用mapperLocations兜底。检查target/classes目录下是否有编译好的XML没有就是Maven资源过滤问题需要在pom.xml配置 。5.2 Session拿不到loginUser登录过滤器拦截循环现象登录成功后跳转到/atm/balance又被拦截器重定向到/login在浏览器里表现为刚登录就跳回登录页。原因登录方法返回的是forward或者视图内部转发URL没变但拦截器判断顺序在Controller之前又一次发现Session里没有用户。解决登录成功一律用redirect让浏览器发起新请求这样最终URL是业务地址Session里也能读到用户。同时检查登录请求是否被exclude-mapping排除掉。5.3 事务不生效取款失败但余额已更新现象故意传入一个超余额的取款金额Service抛异常后数据库里余额还是被扣了。原因最典型的是数据源和事务管理器不一致。比如SqlSessionFactory用的是另一个DataSource Bean事务管理器却指向别的BeanMyBatis的操作不在事务管理范围内。另外如果Service方法内部发生自调用比如一个方法里this.anotherMethod调用同类方法Transactional也会失效。解决确认spring-dao.xml里SqlSessionFactoryBean的dataSource和DataSourceTransactionManager的dataSource是同一个Bean。自调用问题则把逻辑拆到另一个Service对象里通过Autowired注入再调用。5.4 POST请求中文乱码变成问号现象登录卡号没问题但取款备注、转账备注一提交中文JSP页面回显全是??。原因CharacterEncodingFilter没配置forceEncoding或者没放在过滤器最前面。Tomcat在请求体解析和参数读取时如果不指定编码默认用ISO-8859-1。解决web.xml里把filter-mapping放在最上方并设forceEncodingtrue。还要注意JSP文件本身要另存为UTF-8否则数据库里是正常中文JSP页面又显示乱码这种问题最容易误判成数据库编码问题。5.5 Bean被创建两次导致Mapper代理失效现象日志里看到同一个Service类被初始化两次或者Controller注入Mapper时偶尔报NoSuchBeanDefinitionException。原因spring-dao.xml和spring-mvc.xml里都配了 context:component-scan 且base-package都写了com.example导致Service和Controller在两个父子容器里各实例化一次两个容器互相看不到对方的Bean。解决按职责拆分扫描范围。spring-mvc.xml只扫描Controllerspring-service.xml只扫描Service和Repository。DAO层由MapperScannerConfigurer单独处理不参与component-scan。6. 在外部Tomcat部署并验证模拟ATM的“人肉回归测试”步骤项目写完后不要直接在IDE内置Tomcat里跑一次就算验证通过。SSM打包部署到外部Tomcat时ClassLoader、资源路径和依赖缺失问题才会暴露出来。我习惯用Maven打war包再用命令行curl模拟完整业务链路这个步骤能快速筛掉一半隐藏问题。mvn clean package打包成功后会生成target/atm.war把这个war丢到Tomcat的webapps目录下启动Tomcat。如果项目配置了jdbc.properties连接串要确认包含useSSLfalse和serverTimezoneAsia/Shanghai否则MySQL 8下会报时区异常。部署后用curl做一次转账回归测试# 登录保存cookie curl -c cookies.txt \ -d cardNo6222000001password123456 \ http://localhost:8080/atm/login # 查询余额 curl -b cookies.txt \ http://localhost:8080/atm/balance # 转给6222000002金额100 curl -b cookies.txt \ -d toCard6222000002amount100.00 \ http://localhost:8080/atm/transfer验证点建议做成下表场景预期结果关注点余额充足时取款100余额减少100流水新增一条事务提交成功余额不足时取款10000返回“余额不足”余额不变事务回滚生效转账到不存在卡号返回“转入账户不存在”转出余额不应被扣未登录直接访问/atm/balance重定向到/login拦截器生效从那以后每次改完SSM项目我都会强制走一遍“低余额取款”这个场景盯着数据库余额变化确认事务真的回滚了。这个习惯帮我少排查了不知道多少数据不一致问题。希望帮到你。本文还有配套的精品资源点击获取