校园卡系统:SSM框架练手项目的经典之选
校园卡系统SSM练手的经典之作Java后端项目做了好几年回头看学生校园卡管理系统这类带业务闭环的项目反而是我见过最“耐打”的练手载体。它不像电商系统那样堆砌概念也不像纯CRUD管理后台那样平淡而是把充值、消费、挂失、注销这些真实场景串成了一条完整业务链。这个系统用Java SSM框架实现Spring负责对象管理和事务SpringMVC处理请求分发MyBatis管数据持久化。功能上围绕校园卡生命周期展开办卡、充值、刷卡消费、挂失、解挂、注销外加用户管理和消费记录查询。对于一个学过Java基础、想搞懂SSM整合的人来说这套代码能把框架知识、业务设计、数据库建模、前端交互一次性串起来。如果你正准备毕业设计、求职面试或者刚学完SSM想找一个能完整跑通的项目这篇文章会把整个系统的设计思路、核心实现、部署运行和常见坑都拆开讲清楚。我不会只贴代码而是把“为什么这么做”也讲透。1. 这个项目的价值与定位校园卡系统到底在练什么1.1 项目背景与实际需求校园卡是高校里每天都在用的真实场景。学生拿一张卡在食堂刷卡、在超市消费、在机房上机卡里没钱了就充值卡丢了就挂失毕业了就注销。这些操作看似简单落到系统设计里就变成了账户管理、余额变更、状态流转、操作日志等技术问题。这个项目的核心需求其实非常明确学生信息维护学号、姓名、院系、联系方式等基础信息。校园卡账户一张卡对应一个账户账户里有余额有状态正常/挂失/注销。充值功能学生给卡里充钱余额增加生成充值流水。消费功能刷卡扣款余额减少生成消费流水。挂失与解挂卡丢了先挂失冻结消费功能找到后解挂恢复。注销功能卡不再使用账户销户余额清零或退费。管理员后台管理员登录、查询所有记录、统计报表。你看这个业务模型并不会太复杂但覆盖面非常全。对一个SSM阶段的学习者来说它既不会像ERP那样让代码量失控又足够撑起一个完整的Web项目。这是它作为毕业设计和练手项目经久不衰的原因。1.2 适用人群与三种使用场景我先说结论这个系统适合三类人。第一类是准备毕业设计的在校生。SSM框架时至今日依然是很多高校毕设选题的主流技术栈一个业务清晰、代码规范、附带文档和运行视频的项目能让答辩环节省下大量精力。特别是老师问“卡挂失了还能消费吗”“注销后余额怎么处理”这类业务问题你如果亲手做过回答起来完全是另一个状态。第二类是准备Java后端面试的求职者。很多人手里没有能拿得出手的项目面试官问“你项目里遇到过什么问题”答不上来。校园卡系统的并发扣款、事务管理、状态流转设计都是面试官感兴趣的切入点完全可以把项目经验和Java八股文结合起来讲。第三类是刚学完SSM框架的基础学习者。框架学完了但不知道怎么整合三个框架各管什么配置文件怎么写前端请求怎么到后端这个项目能回答这些问题。我见过不少同学把教程里的demo抄了一遍但遇到“注册功能怎么和登录功能共用一套用户体系”这种问题就卡住。而校园卡系统天然包含了多角色、多状态、多流水做一遍框架的整合能力、事务控制能力、分层设计能力都会有一个真实的提升。2. 核心业务与数据库设计把状态流转想清楚再做2.1 五大功能模块的边界划分系统虽然叫“校园卡管理系统”但模块边界一定要清楚。我在设计时把功能拆成了五个核心模块加上一个系统登录模块一共六个用户模块学生信息新增、修改、查询、删除对应校园卡的开卡。账户模块每个用户对应一个卡账户管理余额和状态。充值模块学生充值更新余额写入充值流水。消费模块刷卡消费扣减余额写入消费流水。挂失/注销模块更改账户状态挂失后禁止消费注销后账户失效。系统管理模块管理员登录、退出、密码修改。模块划分的意义在于业务之间通过账户状态和余额解耦而不是把逻辑堆在一个类里。比如挂失操作只更新账户状态字段而消费功能在扣款前检查状态。这样各模块独立出了问题也好排查。2.2 数据库表设计与字段说明数据库设计是这个项目的地基。我当时设计了5张表不多不少刚好覆盖业务又没有冗余student学生表 - id int 主键自增 - student_no varchar(20) 学号唯一索引 - name varchar(50) 姓名 - gender varchar(10) 性别 - department varchar(50) 院系 - phone varchar(20) 联系方式 - create_time datetime 创建时间 card_account卡账户表 - id int 主键自增 - student_id int 关联学生表 - card_no varchar(20) 卡号唯一索引 - balance decimal(10,2) 余额 - status tinyint 账户状态0正常 1挂失 2注销 - create_time datetime 开卡时间 - lose_time datetime 挂失时间 - cancel_time datetime 注销时间 recharge_record充值记录表 - id int 主键自增 - card_id int 关联账户表 - amount decimal(10,2) 充值金额 - create_time datetime 充值时间 - operator varchar(20) 操作人管理员或自助 consume_record消费记录表 - id int 主键自增 - card_id int 关联账户表 - amount decimal(10,2) 消费金额 - shop_name varchar(50) 消费地点 - create_time datetime 消费时间 admin_user管理员表 - id int 主键自增 - username varchar(50) 用户名 唯一 - password varchar(100) 密码 - real_name varchar(50) 姓名这里有个关键设计余额一定要放在账户表里而不是每次通过流水汇总。虽然通过“充值总和 - 消费总和”也能算出余额但那样每次查询都要扫流水表数据量大了性能会很差。实战项目里余额字段直接冗余在账户表流水只做记录和对账这个思路在很多金融系统里也是这么干的。2.3 充值、消费、挂失、注销的状态流转状态是这类系统的灵魂。我见过很多初学者直接把状态写死在代码里比如if (status 1) 就不能消费完全没有状态机的概念。实际上校园卡的状态流转是这样的正常状态可以充值、可以消费、可以挂失、可以注销。挂失状态不能消费、不能注销可以解挂恢复为正常冻结余额但不能清零。注销状态终态所有操作都拒绝。为什么要区分挂失和注销因为挂失是临时性的找到卡还能恢复注销是终态卡作废。如果把挂失做成删除账户那卡找到了就麻烦了。一个状态字段加状态校验就能覆盖这些场景这也是面试官喜欢问的点。这个状态机模型虽然简单但背后的思想是“任何操作前先校验状态合法性”和很多电商系统的订单状态机是同一套逻辑。在代码里我用一个状态常量类来管理避免魔法数字散落各处。3. SSM框架整合与实现要点三个框架各干各的活3.1 Spring SpringMVC MyBatis 分层思路SSM框架组合是经典的“三层架构”落地方式Spring负责对象管理IoC和事务管理AOP。Service层的实例不用自己new由Spring容器统一创建和管理数据库事务通过Transactional声明式配置一个方法就是一个事务单元。SpringMVC负责Web层的请求路由。前端发来的URL请求经过DispatcherServlet分发到对应的Controller方法Controller接收参数、调用Service、返回JSON或页面。MyBatis负责数据持久化。Mapper接口定义方法XML或注解写SQL把结果集映射成Java对象。实际分层的时候我按“Controller - Service - Mapper”拆。Controller层只做参数接收和结果返回不写业务代码Service层做业务逻辑和事务控制Mapper层只做SQL操作。这样每一层都职责单一也方便后续维护。有个新手常犯的错误在Controller里直接操作多个Mapper把业务逻辑堆在控制器里。这样写确实能跑但事务很难控制——如果两个Mapper操作中间抛异常数据就处于不一致状态。正确的做法是把业务逻辑封装到Service方法里加上Transactional一个方法要么全成功要么全回滚。3.2 关键配置与依赖选型SSM整合的配置文件是初学者最头疼的部分。我用的技术版本是Java 8、Maven 3.6、Spring 5.x、SpringMVC 5.x、MyBatis 3.5.x。这套组合非常稳定教程也多出问题容易查。Maven依赖核心就几个spring-context、spring-webmvc、mybatis、mybatis-spring、mysql-connector-java、druid连接池、jackson-databind用于JSON转换。另外还需要servlet-api和jsp-api但这两个要加provided作用域否则会和Tomcat自带的冲突。配置上我分成了三个核心配置文件spring-context.xml配置数据源、SqlSessionFactory、Mapper扫描、Service扫描、事务管理器。spring-mvc.xml配置注解驱动、Controller扫描、视图解析器、静态资源映射。web.xml配置DispatcherServlet和Spring的ContextLoaderListener。这里说一下容易被忽略的细节Spring配置和SpringMVC配置要分清父子容器关系。Spring容器管理Service和MapperSpringMVC容器管理Controller。如果在spring-mvc.xml里扫描了Service类事务会失效。我自己就踩过这个坑后来统一用“spring-context.xml只扫Service和Mapperspring-mvc.xml只扫Controller”的约定问题再没出现过。3.3 前端交互与接口设计思路这个项目的前端我用了JSP Bootstrap AJAX。JSP做页面模板Bootstrap做样式AJAX和JSON做前后端数据交互。虽然现在前端框架Vue很流行但SSM项目搭配JSP是最常见的组合重点是把接口设计清楚。项目的接口按REST风格设计比如POST /card/recharge充值参数为卡号和金额。POST /card/consume消费参数为卡号和金额。POST /card/loss挂失参数为卡号。POST /card/restore解挂参数为卡号。POST /card/cancel注销参数为卡号。GET /record/list查询流水支持分页。所有的接口统一返回JSON格式结构是这样{ code: 200, msg: 操作成功, data: { balance: 98.50 } }前端根据code判断成功失败有数据就渲染data。这种前后端通过JSON交互的方式现在依然是主流做法而且就算以后你想用Vue3重新写前端后端接口完全不用改只要做跨域配置就行。热搜里提到“vue3连接ssm框架”其实原理就是这么简单后端不动把前端换成Vue用axios调接口。4. 核心功能代码实现复盘把关键场景写明白4.1 充值功能的业务逻辑与幂等处理充值逻辑看起来就是“余额加钱、记流水”两步但实际实现有讲究。我先说最核心的Service代码Service public class CardServiceImpl implements CardService { Autowired private CardAccountMapper cardAccountMapper; Autowired private RechargeRecordMapper rechargeRecordMapper; Override Transactional(rollbackFor Exception.class) public Result recharge(String cardNo, BigDecimal amount) { // 1. 校验参数 if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { return Result.error(充值金额必须大于0); } // 2. 查询账户 CardAccount account cardAccountMapper.findByCardNo(cardNo); if (account null) { return Result.error(卡号不存在); } if (account.getStatus() ! CardStatus.NORMAL) { return Result.error(账户状态异常无法充值); } // 3. 更新余额 int rows cardAccountMapper.increaseBalance(cardNo, amount); if (rows 0) { throw new RuntimeException(充值失败); } // 4. 插入流水 RechargeRecord record new RechargeRecord(); record.setCardId(account.getId()); record.setAmount(amount); rechargeRecordMapper.insert(record); return Result.success(充值成功, getBalance(cardNo)); } }这段代码有几个地方值得新手注意。第一金额用BigDecimal而不是double。这是金融相关业务的铁律。double在加减时会丢失精度0.1 0.2可能等于0.30000000000000004这在余额计算里不可接受。BigDecimal按照实际金额精度计算配合数据库的decimal(10,2)金额计算才靠谱。第二先更新余额再插入流水并且包裹在同一个事务里。如果流水插入失败余额更新也会回滚。我在初始化项目时专门测试过手动在插入流水处抛一个异常充值事务整体回滚余额不变。这个验证过程我建议你也做一遍能直观理解Transactional的作用。第三幂等性问题。如果用户点了两次充值按钮请求重复提交会不会充两次这个系统用了最简单的前端防重复提交按钮置灰但后端更严谨的做法是加一个业务订单号记录到单独的流水表通过唯一索引约束同一个订单号只能成功一次。面试被问到“怎么防止重复支付”时这是一个很好的延伸点。4.2 消费扣款与余额校验的代码细节消费是另一个核心操作逻辑上有“先查后扣”和“直接扣”两种写法。先看一种更稳妥的做法Override Transactional(rollbackFor Exception.class) public Result consume(String cardNo, BigDecimal amount, String shopName) { // 1. 校验金额 if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { return Result.error(消费金额必须大于0); } // 2. 查询账户并校验状态 CardAccount account cardAccountMapper.findByCardNo(cardNo); if (account null) { return Result.error(卡号不存在); } if (account.getStatus() ! CardStatus.NORMAL) { return Result.error(当前状态不可消费); } // 3. 校验余额 if (account.getBalance().compareTo(amount) 0) { return Result.error(余额不足); } // 4. 扣减余额 int rows cardAccountMapper.decreaseBalance(cardNo, amount); if (rows 0) { throw new RuntimeException(扣款失败); } // 5. 插入消费流水 ConsumeRecord record new ConsumeRecord(); record.setCardId(account.getId()); record.setAmount(amount); record.setShopName(shopName); consumeRecordMapper.insert(record); return Result.success(消费成功, getBalance(cardNo)); }这里有一个面试高频问题“先查询余额再扣款”在高并发下会不会有问题答案是会。假设两个请求同时读到余额是10元两个都扣8元各自判断余额够最后余额变成2元但实际上两笔消费共扣16元超扣了。解决方案有两种一是用数据库行锁SELECT ... FOR UPDATE把账户行锁住串行处理二是用乐观锁更新的时候加条件where balance amount。MyBatis的decreaseBalanceSQL可以写成UPDATE card_account SET balance balance - #{amount} WHERE card_no #{cardNo} AND balance #{amount}这样数据库层面就保证了余额够才能扣成功如果返回影响行数为0说明余额不足或卡号不存在。数据库的原子操作比Java代码里的“先查后改”可靠得多这也是一个很好的面试谈资。在日常单机学习环境里很多同学察觉不到这个差异但在用户量稍大的场景这是一个非常关键的正确性设计。4.3 挂失、注销的状态机管理代码挂失和注销放在一起讲因为它们的本质都是改状态。区别在于挂失之后可以解挂注销是终态。我在CardStatus类里定义了状态常量public class CardStatus { public static final int NORMAL 0; // 正常 public static final int LOSS 1; // 挂失 public static final int CANCEL 2; // 注销 }挂失的Service逻辑Override Transactional(rollbackFor Exception.class) public Result loss(String cardNo) { CardAccount account cardAccountMapper.findByCardNo(cardNo); if (account null) { return Result.error(卡号不存在); } if (account.getStatus() ! CardStatus.NORMAL) { return Result.error(只有正常状态的卡才能挂失); } account.setStatus(CardStatus.LOSS); account.setLoseTime(new Date()); cardAccountMapper.updateStatus(account); return Result.success(挂失成功); }对应的消费方法里已经校验了status ! NORMAL就拒绝所以挂失一生效消费功能立刻失效。解挂就是把状态改回NORMAL同时清空挂失时间。注销的逻辑也是类似但要把状态改成CANCEL而且注销前最好校验余额为0避免资金遗留问题。你可以把这个“注销前余额检查”作为扩展点在项目里加上余额清零或退费的功能。我特别想强调一点状态机的核心不是怎么写代码而是怎么设计状态和操作之间的约束关系。这个项目里用的“状态先校验操作后流转”模式在真实的交易系统、审批系统里非常常见。把这三行状态判断吃透比背十道面试题更有用。4.4 登录与权限拦截的核心写法系统有一个管理员登录功能。登录校验通过后把用户信息存到Session里。为了防止未登录用户直接访问后台接口我加了一个SpringMVC的拦截器public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(adminUser); if (user null) { // 判断是否AJAX请求返回不同结果 response.setContentType(application/json;charsetutf-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } return true; } }然后在spring-mvc.xml里注册拦截器配置不拦截登录接口和静态资源mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/admin/login/ mvc:exclude-mapping path/static/**/ bean classcom.campus.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors这个拦截器是SSM项目权限控制的经典写法。拦截器只负责“是否登录”什么时候用户能做什么操作交给Service层的状态校验去做。把这两个层面分开逻辑才清晰。关于密码存储项目里用了MD5加密。但真实系统中密码加密建议用BCrypt这类带盐的算法MD5已经不够安全。你可以把这个作为项目优化点提出来在面试里会加分。5. 部署运行关键操作与常见问题排查5.1 环境准备与部署全流程把这个系统跑起来你需要准备的东西不多JDK 1.8Maven 3.6Tomcat 8.5或9.0MySQL 5.7或8.0IDEA开发工具部署步骤我整理成了一段流程每一步都很关键创建数据库campus_card执行项目里的init.sql初始化脚本脚本会建表并插入一条测试管理员账号和几条学生数据。修改jdbc.properties里的数据库连接信息重点是用户名、密码、URL里的serverTimezoneAsia/Shanghai。用IDEA打开Maven项目等待依赖下载完成。如果网络不好可以在settings.xml里配置阿里云镜像。配置Tomcat把项目部署到Tomcat点击启动。浏览器访问http://localhost:8080/项目名/用管理员账号登录。很多新手在第五步前就卡住了大多数情况是数据库连不上或者依赖没下来。先单独写好数据库连接测试再启动项目能帮你快速定位问题。5.2 常见问题排查速查表我把自己跑项目时遇到频率最高的问题整理成了表你可以直接照这个排查现象原因解决办法Tomcat启动报ClassNotFoundExceptionMaven依赖没下载完整检查settings.xml镜像配置mvn clean后重新导入项目启动时提示Port 8080 was already in use端口被占用换端口或netstat -ano页面中文乱码编码不一致JSP页面、数据库连接URL、Tomcat编码统一为UTF-8数据库连接失败Access denied用户名密码或权限问题检查账号密码GRANT ALL ON campus_card.* TO rootlocalhost登录后页面报404拦截器拦截了静态资源或路径不对检查拦截器exclude配置检查请求路径运行时报OutOfMemoryError: insufficient memoryJVM启动内存不足在IDEA的VM options里加-Xms256m -Xmx512m查询结果返回null但数据库有数据字段映射不正确开启MyBatis驼峰映射mapUnderscoreToCamelCasetrue最后一条我多说一句。Java实体类的属性命名一般是驼峰式比如studentNo数据库字段是下划线式student_noMyBatis默认不会自动映射需要在application.properties或mybatis-config.xml里开启驼峰映射。不开启的话查出来的数据对象就是一个个null字段这是SSM项目里经典到不能再经典的坑。5.3 如何把项目讲给面试官听很多人项目做完了但面试被问几句就露怯。校园卡系统虽然不大但只要讲出“设计感”依然很有说服力。我的建议是顺着三条线准备一是事务控制线。充值、消费为什么加TransactionalSpring的事务传播行为有哪几种默认的REQUIRED是什么意思一个Service方法调另一个Service方法事务会合并吗这些八股文知识点放在这个项目场景里讲比干背效果好得多。二是并发安全线。两个请求同时消费怎么办怎么用数据库的行锁或乐观锁保证不超扣MySQL默认隔离级别是什么SELECT ... FOR UPDATE会锁行吗这些问题的答案在代码里都有对应位置面试官问起来你可以直接说“我在这段消费逻辑里是怎么处理的”。三是状态设计线。为什么挂失和注销分开状态放在数据库里是什么类型如果以后要扩展“冻结状态”怎么办回答的时候把状态机的思路讲出来面试官就知道你不是只会CRUD的。另外热搜词里的java面试八股文、HashMap底层、冒泡排序这些是面试常考的基础题。但项目经验存在的意义不是代替这些基础题而是让你在回答完基础题之后能拿出一个具体的场景证明“我会用”。把这个校园卡系统的每条业务逻辑吃透比背一百道题有用。5.4 版本与扩展方向如果你用的是Java 11以上或Spring Boot搭建方式会有一些差异。SSM毕竟是手动整合三个框架配置较多而Spring Boot把很多配置都自动完成了。但我的看法是练习阶段建议先用SSM手动搭一遍这样才能理解Spring Boot帮我们省掉了什么。等你把SSM的配置流程走顺了再切Spring Boot会非常快。项目本身也可以扩展。热搜词里提到Vue3你可以用Vue3 Element Plus重写前端页面把JSP替换成前后端分离架构后端只要加一个CORS跨域配置即可。也可以把“注销”扩展成完整的“退费流程”把“消费”扩展成“支持不同商户类型”。这些扩展在文档和答辩视频里都是亮点。最后再分享一个小细节整个项目里我最满意的是那张流水表的设计。刚开始我也觉得充值记录和消费记录建一张表就够了后来想了想还是拆开了。因为充值记录关注的是“谁给我充了多少钱”消费记录关注的是“我在哪花了多少钱”两个查询维度完全不同拆开后统计和分页都轻松。这个简单的表设计决策后来在写对账功能的时候帮了大忙。如果你也在做校园卡系统或者准备做类似的SSM练手项目记住一个原则先把状态和余额的流转画出来再动手写代码。业务跑通了剩下的都是细节。踩过几次坑之后你会发现这个项目带给你最大的收获不是那些代码而是对“一个完整系统是怎么运转的”这件事的理解。