ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSM框架实战:火车票务管理系统设计与实现全解析

SSM框架实战:火车票务管理系统设计与实现全解析 1. 项目概述与整体设计思路1.1 核心需求解析SSM283这个编号稍微有点经验的人一看就知道是课程设计或毕业设计类项目的命名习惯。整套系统基于经典的SSM框架也就是Spring、SpringMVC、MyBatis三件套做的是列车火车高铁票务信息管理系统。说白了就是一个能在网页上查车次、订火车票、管理订单的后台系统类似简化版12306的管理后台加上前端购票页面。这类项目在大学里出现频率极高原因很实在SSM框架是目前Java Web开发岗位面试中最常被问到的技能组合之一用它做一个完整业务系统既能锻炼分层架构能力又能把数据库设计、事务管理、MVC模式全部串起来。很多同学做完这个项目后拿去写进简历里面试官几乎每次都会追问订单流程怎么实现、并发情况下余票怎么控制这类问题。这个系统它解决的问题很明确把传统的车站窗口售票搬到网页上。用户可以在线注册登录查询车次选择车票类型商务座、一等座、二等座、硬卧、软卧等提交订单并支付管理员则通过后台管理车次信息、票务信息、订单状态、用户账号等。适合的人群包括正在做课设的在校学生、准备秋招的Java开发求职者以及想系统梳理SSM整套开发流程的自学者。1.2 技术选型背后的逻辑既然标题锁定了SSM框架那我们得先把这个选型逻辑说透。如果你是2024年之后才开始学Java Web可能会觉得奇怪现在不都是Spring Boot一把梭吗怎么还有人用SSM这种传统组合这里有个很现实的行业背景高校课程大纲更新速度跟不上企业技术迭代速度很多学校至今仍然以SSM作为Java Web课程的核心教学内容原因在于SSM能更清晰地暴露框架底层原理。Spring Boot的自动配置确实方便但正因为它太方便了很多学生不知道内部发生了什么。而SSM每一层都是显式配置理解它之后再上手Spring Boot几乎是无缝切换。具体到本项目Spring负责Bean管理、声明式事务控制是整个应用的黏合剂SpringMVC负责接收请求、路由分发、返回视图是Web层的核心MyBatis负责数据库访问通过XML或注解方式写SQL灵活可控数据库方面经典搭配是MySQL 5.7 Navicat管理工具。JDK选择1.8服务器用Tomcat 8.5前端则可以选择JSP Bootstrap组合。这套组合虽然不算新潮但胜在稳定、资料多、问题排查容易——毕竟网上关于SSM的报错解决方案基本能覆盖你日常开发中遇到的所有坑。1.3 项目目录结构与分层思想拿到项目后第一步不是急着写代码而是先规划好包结构。我的建议是按照标准的Controller、Service、Mapper三层来划分这样不仅代码清晰答辩的时候也好讲。src/main/java ├── com.example.train │ ├── controller // 控制层接收前端请求 │ │ ├── UserController.java │ │ ├── TrainController.java │ │ └── OrderController.java │ ├── service // 业务层核心逻辑处理 │ │ ├── UserService.java │ │ └── impl │ │ └── UserServiceImpl.java │ ├── mapper // 数据访问层接口 │ │ ├── UserMapper.java │ │ └── TrainMapper.java │ ├── entity // 实体类对应数据库表 │ │ ├── User.java │ │ ├── Train.java │ │ └── Order.java │ └── common // 公共工具类、结果封装 │ └── Result.javaController只负责接收参数和返回结果不写业务逻辑Service层处理具体业务比如下单要校验余票、扣减库存、生成订单号Mapper层只写SQL和数据映射。这样的好处是哪一层出了问题直接定位到对应层排查效率高得多。我见过不少学生把SQL直接写在Controller里或者把业务逻辑堆在JSP页面里这样做出来的系统一旦复杂度上来改一个需求可能要动三个地方非常痛苦。2. 核心功能模块设计与数据库搭建2.1 数据库表结构详解车票系统的核心数据表无非这几张用户表、车次表、订单表另外可以考虑加一张余票表或类型的复合字段。先看基础表结构这是整个项目的底座建表之前建议画一张简单的ER图理清实体关系再动手。用户表t_userCREATE TABLE t_user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码(MD5加密存储), real_name varchar(20) DEFAULT NULL COMMENT 真实姓名, id_card varchar(18) DEFAULT NULL COMMENT 身份证号, phone varchar(11) DEFAULT NULL COMMENT 手机号, role int(1) DEFAULT 0 COMMENT 0-普通用户 1-管理员, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;车次表的设计稍微讲究。不同类型的列车属性差异很大高铁G字头有商务座、一等座、二等座普通列车K/T/Z字头有硬座、硬卧、软卧。最省事的设计方案是把这些作为字段直接列出来哪种车型用不上的字段留NULL即可。CREATE TABLE t_train ( id int(11) NOT NULL AUTO_INCREMENT, train_no varchar(10) NOT NULL COMMENT 车次号如G1234, train_type varchar(10) DEFAULT NULL COMMENT 车型高铁/动车/普快, start_station varchar(30) NOT NULL COMMENT 始发站, end_station varchar(30) NOT NULL COMMENT 终点站, start_time time DEFAULT NULL COMMENT 发车时间, end_time time DEFAULT NULL COMMENT 到达时间, run_time varchar(20) DEFAULT NULL COMMENT 运行时长, business_seat_price decimal(10,2) DEFAULT NULL COMMENT 商务座票价, first_class_price decimal(10,2) DEFAULT NULL COMMENT 一等座票价, second_class_price decimal(10,2) DEFAULT NULL COMMENT 二等座票价, business_seat_count int(6) NOT NULL DEFAULT 0 COMMENT 商务座余票, first_class_count int(6) NOT NULL DEFAULT 0 COMMENT 一等座余票, second_class_count int(6) NOT NULL DEFAULT 0 COMMENT 二等座余票, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表需要重点关注因为它是整个系统业务逻辑最复杂的地方。状态字段用整型存储0表示待支付、1表示已支付、2表示已出票、3表示已退票、4表示已过期这样比字符串更省空间也方便条件查询。CREATE TABLE t_order ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int(11) NOT NULL COMMENT 下单用户ID, train_id int(11) NOT NULL COMMENT 车次ID, seat_type varchar(10) NOT NULL COMMENT 座位类型, seat_price decimal(10,2) NOT NULL COMMENT 票价, passenger_name varchar(20) NOT NULL COMMENT 乘车人姓名, passenger_id_card varchar(18) NOT NULL COMMENT 乘车人身份证号, status int(1) DEFAULT 0 COMMENT 订单状态, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL COMMENT 支付时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 为什么余票要存字段而不是单独建表这个问题我在答辩时被问过很多次。有些同学可能会想余票是不是应该单独建一张库存表跟订单表做关联本质上这两种方案都能跑通但放在一个车票管理系统里直接作为字段存是更务实的做法。原因很简单车次的座位类型数量有限且固定不会像电商库存那样随时可能有几百种SKU动态出现。直接加字段的方式让查询余票变成一次普通查询不需要额外的表关联写起来和讲起来都省事。另外退票时直接对字段做自增更新语句简洁明了UPDATE t_train SET second_class_count second_class_count 1 WHERE id ?当然这也带来了一个并发问题同一趟车最后一张票两个人同时下单怎么办这个我在本文第4.3节会详细讲事务和锁的处理。2.3 MyBatis 的实体映射与动态 SQL 写法实体类这块以Train.java为例字段和数据库表列保持一致给个有代表性的片段public class Train { private Integer id; private String trainNo; private String trainType; private String startStation; private String endStation; JsonFormat(pattern HH:mm) private Date startTime; JsonFormat(pattern HH:mm) private Date endTime; private String runTime; private BigDecimal businessSeatPrice; private BigDecimal firstClassPrice; private BigDecimal secondClassPrice; private Integer businessSeatCount; private Integer firstClassCount; private Integer secondClassCount; // 省略getter/setter }Mapper层是MyBatis的核心阵地。车票查询通常需要组合条件比如按出发地、目的地、发车日期筛选这时动态SQL能极大减少代码量select idqueryTrainList resultTypecom.example.train.entity.Train SELECT * FROM t_train where if teststartStation ! null and startStation ! AND start_station #{startStation} /if if testendStation ! null and endStation ! AND end_station #{endStation} /if if testtrainType ! null and trainType ! AND train_type #{trainType} /if /where ORDER BY start_time /select这一段能同时处理用户不带任何条件查全部车次、只填出发地/目的地、或者三者组合查询的场景。where标签会自动处理第一个条件前面的AND省去写11这种投机取巧的写法。3. 实操过程与核心功能实现3.1 下单购票全流程追踪下单功能是整个系统的心脏。前端用户点击“预订”按钮后后端在OrderController接收请求然后走Service层处理。整个过程可以拆成四步校验乘车人信息、锁定余票、扣减库存、生成订单。关键代码在OrderServiceImpl里Override Transactional(rollbackFor Exception.class) public Result createOrder(OrderRequest request) { // 1. 查询车次信息 Train train trainMapper.selectById(request.getTrainId()); if (train null) { return Result.error(车次不存在); } // 2. 判断对应的座位类型余票是否充足 Integer remainCount getRemainCountBySeatType(train, request.getSeatType()); if (remainCount null || remainCount 0) { return Result.error(该座位类型余票不足); } // 3. 扣减余票乐观锁只更新条件为余票0 int updateResult trainMapper.decreaseSeatCount(request.getTrainId(), request.getSeatType()); if (updateResult 0) { return Result.error(购票失败请重试); } // 4. 生成订单号并插入订单 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(request.getUserId()); order.setTrainId(train.getId()); order.setSeatType(request.getSeatType()); order.setSeatPrice(getPriceBySeatType(train, request.getSeatType())); order.setPassengerName(request.getPassengerName()); order.setPassengerIdCard(request.getPassengerIdCard()); order.setStatus(0); // 待支付 orderMapper.insert(order); return Result.success(下单成功, orderNo); }注意第3步Mapper里的扣减语句不是先查询再修改而是直接用一条SQL完成条件更新update iddecreaseSeatCount UPDATE t_train SET choose when testseatType businessbusiness_seat_count business_seat_count - 1/when when testseatType firstfirst_class_count first_class_count - 1/when otherwisesecond_class_count second_class_count - 1/otherwise /choose WHERE id #{trainId} AND choose when testseatType businessbusiness_seat_count 0/when when testseatType firstfirst_class_count 0/when otherwisesecond_class_count 0/otherwise /choose /update这一步就是为了防止并发超卖。数据库层面的条件更新加上事务控制保证了同一时刻多个用户同时下单时只有余票充足的那个请求能真正扣减成功。这个细节如果做好了面试官提问并发时候你能直接给出代码级别的回答比单纯背概念要有说服力得多。3.2 订单号生成策略订单号不能直接用数据库自增ID因为既要保证唯一性最好还能体现一定的时间信息。我在这个项目里采用的是一个简单但非常实用的组合方案private String generateOrderNo() { SimpleDateFormat sdf new SimpleDateFormat(yyyyMMddHHmmss); String timeStr sdf.format(new Date()); return timeStr String.format(%04d, new Random().nextInt(10000)); }比如202411201530259384解析起来很直观2024年11月20日15点30分25秒下单的订单后四位9384是随机数。虽然理论上随机数有极小概率冲突但因为有唯一索引兜底即便冲突了重新执行一次即可。说白了这就是把可读性和唯一性做了一个平衡完全够用。没必要上UUID32位长字符串不便于人工核对更没必要引入雪花算法单机应用场景完全是杀鸡用牛刀。3.3 管理员后台功能实现后台管理模块在Controller层面需要做一个简单的权限拦截。最经典的做法是编写一个拦截器实现HandlerInterceptor接口在preHandle方法里判断session中是否有登录用户以及角色是否为1管理员public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }然后在SpringMVC配置文件中注册拦截器指定拦截的路径模式mvc:interceptors mvc:interceptor mvc:mapping path/admin/**/ mvc:exclude-mapping path/admin/login/ bean classcom.example.train.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptors车次管理这块的增删改查是典型的基础操作但有一个容易出错的地方修改车次信息时余票字段千万不要直接整行覆盖。比如用户A正在订票管理员此时修改了该车次的二等座票价如果用的是updateById方法可能会把用户A下单时尚未提交的余票更新结果一并覆盖掉。安全起见车次编辑页面应禁止修改余票字段或者修改时带上版本号做乐观锁控制。3.4 Web层视图与请求联动SSM项目虽然没有前后端分离但Controller返回视图时也要遵循约定优于配置的原则。用户端页面放在WEB-INF下面的jsp目录通过SpringMVC的视图解析器统一加载bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /beanController中返回字符串会自动拼接前后缀找对对应的JSP文件RequestMapping(/train/list) public String list(Model model) { ListTrain trains trainService.queryAll(); model.addAttribute(trains, trains); return train_list; }页面渲染用JSTLEL表达式即可核心查询结果循环赋值到表格里。JSP页面中顺便加上Bootstrap的CDN界面能用即可。我对前端的要求就是干净清晰别太花哨毕竟评分核心在后端业务逻辑上。3.5 关键配置文件的踩坑总结SSM项目配置繁多的确是让人头疼的点但配置文件基本是一次性工作配好之后不需要频繁改动。spring-mybatis.xml中需要注意两点数据库信息用外部properties引入不要硬编码context:property-placeholder locationclasspath:db.properties/SqlSessionFactoryBean里必须配置typeAliasesPackage这样在Mapper XML里写resultType时可以用实体类的小驼峰名称否则得写全限定名bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.example.train.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean最后配置Mapper扫描器把接口交给Spring管理bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.train.mapper/ /bean说实话SSM的配置第一次看着繁琐但当你把这些XML全部写完整个框架的依赖关系、Bean生命周期你就理解得七七八八了。这也就是为什么很多面试官对使用SSM的简历候选人反而更有好感——能徒手配SSM说明框架原理是真正吃透了而不只是背了两个Spring Boot注解。4. 常见问题与排查技巧实录4.1 Maven依赖冲突导致的NoSuchMethodError这个几乎是SSM项目新人必踩的坑。症状表现是代码编译没问题部署到Tomcat后启动正常一旦访问某个页面报java.lang.NoSuchMethodError。原因是各依赖包版本不兼容。最常见的是Spring 4.x和Spring 5.x的jar混用比如在Maven里同时引了spring-webmvc 4.3.18和spring-context 5.0.2运行时会随机出现各种诡异异常。另外注意shiro、quartz等框架如果带了传递性依赖也可能会引入不同版本的spring-core。解决方法很简单所有的Spring相关jar版本号统一尽量集中管理。在pom.xml里用properties统一声明版本号。properties spring.version5.1.8.RELEASE/spring.version mybatis.version3.4.6/mybatis.version /properties再用dependencyManagement统一管理版本。4.2 请求参数中文乱码的完整解决方案页面表单提交的中文到后台变问号这是每个Java Web开发都经历过的问题。先说原因浏览器使用UTF-8编码提交但Tomcat默认的解码方式是ISO-8859-1。解决方法分两步缺一不可。第一步SpringMVC在web.xml里配置字符编码过滤器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注意forceEncoding一定要设成true只设encoding在某些情况下不会强制生效。第二步修改Tomcat的server.xml中Connector配置加上URIEncodingUTF-8。这两个都配置好之后GET和POST请求中的中文基本不会再出问题。如果数据库查看时发现还是乱码再检查MySQL连接串加不加characterEncodingutf8参数。4.3 并发下单时数据一致性的操作细节这是系统在真正并发访问时会暴露出来的问题但同时也是面试的高频考点。先说结论单机环境下最稳妥的方案是数据库行级锁——用SELECT ... FOR UPDATE对车次记录加锁然后检查余票、扣库存、生成订单最后提交事务释放锁。Transactional(rollbackFor Exception.class) public Result createOrderWithLock(OrderRequest request) { // 查询车次信息并锁定该行记录 Train train trainMapper.selectByIdForUpdate(request.getTrainId()); if (train null) { return Result.error(车次不存在); } Integer remainCount getRemainCountBySeatType(train, request.getSeatType()); if (remainCount 0) { return Result.error(余票不足); } // 扣减余票... }FOR UPDATE会让数据库对该条记录加排他锁其他事务在这个事务提交前无法修改这条记录。但高并发场景下对同一行数据加锁会导致其他购票请求排队所以该方案适合小规模课设系统完全没问题。万一遇到频繁大并发抢票那就需要Redis令牌、MQ削峰这类进阶手段了但那属于生产级系统的范畴了。4.4 页面数据渲染不出来的诊断套路汇总页面打开是空白或者数据列表为空这个问题的排查路径如下先F12看Network请求是否返回500。如果是500立刻打开Tomcat日志定位异常堆栈80%以上的可能是Mapper XML里的SQL写错或者实体类字段与数据库列对不上。出现BadSqlGrammarException时去Navicat里执行一下SQL确认语法没有问题后再回来检查Mapper映射。如果是200但数据为空先确认Controller里是否把数据放进了model或request中。另外JSP页面EL表达式如果输出一堆${trainList}这种字面量说明没有引入JSTL标签库% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %还有一种情况是页面用了${pageContext.request.contextPath}拼静态资源路径但这个值在纯HTML中取不到只适用于JSP动态页面。如果用Thymeleaf替换JSP则要确认模板引擎配置中的prefix路径是否与html实际存放目录一致。按照我的经验上面这几轮排查下来覆盖了90%以上的页面渲染问题。剩下的10%基本是Tomcat缓存导致的部署版本不是最新代码重启Tomcat前先clean一下target目录再重新打包问题就解决了。5. 项目优化空间与学习拓展建议5.1 从SSM平滑过渡到Spring Boot近几年来企业招聘岗位上的要求普遍从SSM切换成了Spring Boot但两者的底层完全一致。Spring Boot本质上就是Spring框架的再封装自动配置帮你省去了写XML的步骤但Bean管理、AOP、事务传播这些核心概念没变。所以这个项目如果后续想升级最自然的路径就是改造成Spring Boot版本。改造时重点留意三个地方数据源配置从XML文件改到application.yml使用Druid连接池替代c3p0或dbcp事务管理从tx:annotation-driven切换为EnableTransactionManagement自动装配MyBatis的Mapper扫描从XML配置改为MapperScan注解。改造过程中你会发现理解了SSM的原理Spring Boot的每一处自动配置你都能找到对应关系学习成本比直接从零学Spring Boot的人低得多。5.2 给答辩和面试的额外加分解读做完系统只是第一步能讲清楚设计思路才是让评委或面试官印象分拉满的关键。这里给你三个切入点第一个切入点事务为什么要放在Service层。如果你在Controller里写Transactional事务其实也能生效但public方法默认有、private方法默认没有这个机制要让人理解。最关键的一句是Controller负责的是HTTP请求转换Service负责的是业务逻辑完整性事务属于业务层面的保证所以必须下放到Service层。第二个切入点为什么余票扣减不用先查出来再修改。这会自然引出并发安全和数据库原子性的讨论。你先说了答案——条件更新解决超卖问题——然后反向推导出如果不这样做两个用户同时读到余票为1各自生成订单最终余票变成-1的场景逻辑自洽又完整。第三个切入点订单状态为什么用int而不是字符串。直接回答节省存储、检索快、扩展性好配合一个简单的枚举类就能在Java层做语义化映射。面试官若追问为什么不直接用枚举再补充说MyBatis对枚举类型需要额外配置typeHandler默认的EnumTypeHandler会存枚举名字段语义是清楚了但查询条件判断时需要转一下代码量反而增加了。5.3 后续功能扩展的实际建议这套票务系统后续可以加的功能非常多但优先级从高到低排序如下。第一个建议加支付模拟。不需要真正对接支付宝微信支付在订单详情页模拟一个支付倒计时窗口超时未支付自动关闭订单并恢复余票这就能把整个状态机闭环串起来。也顺便考验你对定时任务和延时关闭状态的掌控能力。第二个建议加日志切面。用Spring AOP做一个全局日志切面统一打印每个请求的请求路径、参数、处理时长和返回结果。既能练习AOP的应用也对排查线上问题特别有实际意义。日志框架用Log4j2或Logback都行。第三个建议加实时余票检查接口。前端页面每5秒动态刷新余票数用Ajax请求调后端的余票查询接口。这种轮询方案能直观地让用户看到票量变化展示效果好。当然也可以尝试把一个更进阶的版本改造成前后端分离的形式后端只提供JSON接口前端用Vue或React配合Element UI重写页面。但很多学校课设的评分标准不要求做这个如果你要的是稳妥交付JSP版本已经绰绰有余了。6. 写在最后的项目复盘这套SSM车票管理系统做下来我自己最大的感受是它看上去是个小项目但麻雀虽小五脏俱全。从数据库设计、框架配置到业务逻辑处理再到并发事务优化每个环节都是Java Web开发的基本功。认真做完这个项目的人对SSM框架的理解和对MVC的分层意识一定会比单纯看视频刷教程强出一大截。我个人在实际开发过程中体会最深的其实是心态层面的东西。起初跟着教程敲代码很顺利但遇到一个中文乱码问题就卡了两天后来才发现只是Tomcat和高版本MySQL字符校验规则变更导致的小概率问题。这类问题虽然烦人但解决后对整个技术栈的理解反而更扎实了。所以我想说的是如果你在做这个项目时被某个报错卡住了别慌把异常信息完整地用搜索引擎搜一搜十有八九能查到前人的记录。调试的过程本身就是最好的学习过程。最后分享一个小技巧每次改完代码重新部署前养成先执行Maven clean再打war包的习惯。别小看这一步它能避免大量由于本地target目录残留旧class文件导致的灵异bug。等你做完整个项目再回头看自己的代码如果每一行都能说清楚为什么这么写那这个项目就算真正做到位了。
返回列表