☰
基于Spring Boot+MyBatis的学生火车票订票系统实战拆解
2026/10/21 2:44:56 网站建设 项目流程

简介:一套基于JAVAEE的学生火车票订票系统项目源码,面向Java Web初学者及毕业设计者,覆盖学生半价购票、车次查询、订单管理等典型业务场景。资源共318个文件,压缩包大小24.27MB,内含72个jar依赖库、38个java源文件、33个jsp页面、sql数据库脚本及大量图片样式文件,同时包含class编译文件便于直接对照运行。系统功能包括后台列车信息维护、学生优惠折扣、团购订票、前端搜索选座支付流程等,目录结构完整,可导入IDE快速部署。已有3249人学习下载。借助源码可系统学习JAVAEE中Servlet/JSP、Spring MVC、MyBatis等整合方式,掌握车次维护、订单增删改查、会话管理等实现细节,数据库脚本和前端资源也为二次开发、课程设计与毕业答辩提供了实用参考。

1. JAVA学生火车票订票系统:一个能把“抢票场景”做透的练手项目

每年临近放假,校园里的订票需求都会集中爆发:热门车次放票十几秒就被抢空,后台余票跟实际订单对不上,偶尔还会出现重复下单。与其用一张 Excel 手工登记,不如把“学生火车票订票系统”直接部署到实验室服务器上,让几个人同时登录、查询、下单,观察余票怎么被扣减、订单状态怎么流转。这个项目定位很清晰:Spring Boot 全家桶写后端,MyBatis 操作数据库,Thymeleaf 渲染页面,把登录、车次检索、下单、订单管理这条主链路完整串起来。适合两类人:一是刚学完 JavaWeb 想找个中等复杂度项目练手的人,二是手里已经有一套半成品、想知道怎么把订票系统改得更健壮的开发者。

2. 项目摸底:技术栈选型、目录结构与一次完整请求链路

2.1 技术栈为什么这样选:Spring Boot + MyBatis 是最稳的学生项目组合

这类订票系统最常见的形态是单体 Web 应用,后端用 Spring Boot 并不需要复杂的微服务架构。Spring Boot 自带内嵌容器,写完代码直接mvn spring-boot:run就能启动,省掉部署 Tomcat 的步骤。数据层我建议用 MyBatis 而不是 JPA,原因很实际:订票业务里大量涉及动态条件查询,比如“从北京到上海、只看高铁、余票大于 0、按发车时间排序”,MyBatis 的动态 SQL 写起来比 JPA 派生方法直观得多。把 SQL 写在 Mapper.xml 里,调优时也能直接拿到执行计划去优化。

前端不必做成前后端分离。用 Thymeleaf 做服务端渲染,页面和 Controller 之间的传参方式简单,学习成本低。对于学生项目来说,登录页、查询页、订单页总共也就十来个页面,不需要引入 Vue/React 那套工程链。会话保持用 HttpSession,配合拦截器做登录校验,这类传统方案在校园网环境下表现稳定,也方便在实验报告里写清楚一次请求的生命周期。

2.2 后端目录结构一页纸:从 controller 到 mapper 的分层边界

下面是一个我在拆解这类项目时比较推荐的目录结构,也符合 Spring Boot 标准的米色风格:

train-ticket/ ├── pom.xml ├── src/main/java/com/train/ │ ├── TrainTicketApplication.java │ ├── controller/ │ │ ├── LoginController.java │ │ ├── TrainController.java │ │ ├── OrderController.java │ │ └── AdminController.java │ ├── service/ │ │ ├── TrainService.java │ │ ├── OrderService.java │ │ └── impl/ │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── TrainMapper.java │ │ └── OrderMapper.java │ ├── entity/ │ │ ├── User.java │ │ ├── Train.java │ │ └── Order.java │ ├── dto/ # 查询条件、下单入参的封装 │ │ ├── TrainQueryDTO.java │ │ └── OrderCreateDTO.java │ └── config/ │ ├── InterceptorConfig.java │ └── WebConfig.java ├── src/main/resources/ │ ├── application.yml │ ├── mapper/ │ │ ├── UserMapper.xml │ │ ├── TrainMapper.xml │ │ └── OrderMapper.xml │ ├── static/ │ └── templates/ │ ├── login.html │ ├── index.html │ ├── train-list.html │ └── order-list.html └── sql/ └── init.sql

控制层只做参数接收和视图转发,业务逻辑放到 Service,数据库操作收敛到 Mapper。订单里涉及余票扣减,这个操作必须放在 Service 层用事务方法封装,不能在 Controller 里散落着写updateSQL。DTO 的存在是为了避免把前端传来的查询条件直接塞进实体类,TrainQueryDTO 里可以定义分页页码、出发日期范围,这些字段不需要和被映射到表结构里的 Train 实体搅在一起。

2.3 一个订票请求从点击到入库的完整链路

以用户查询车次为例:浏览器提交GET /train/query?from=北京&to=上海&date=2025-06-01,请求先经过自定义拦截器,检查 Session 里有没有 user 对象,没有就重定向到/login。通过之后进入TrainController.query(),Controller 接收参数并组装TrainQueryDTO,调用TrainService.queryTrains()。Service 在这里可以做两件事:把字符串日期解析成LocalDate,再补一个“只查询未发车车次”的过滤条件,接着调用TrainMapper.selectByCondition。最终 SQL 在 TrainMapper.xml 里动态拼接,返回 List,渲染到train-list.html。

下单请求的链路会再长一点:OrderController.create()接收车次 ID 和乘车人信息,然后到OrderService.createOrder()里开启事务。这个事务方法先对车次记录加行锁,检查余票,扣减余票,插入订单表,最后提交事务。整套操作是这次拆解里的重点,后面会在第 4 章展开讲。

3. 把项目跑起来:环境准备、数据库初始化与启动验证

3.1 环境清单:JDK、Maven、MySQL 怎么搭配才不折腾

跑这个项目建议用 JDK 8 或者 JDK 11,Spring Boot 2.x 对这两个版本的支持最好。Maven 用 3.6 以上版本就够了,关键是通过 Maven 把依赖下载到位。数据库我一般用 MySQL 5.7 或 8.0,两者对这套代码都没有兼容性问题。前端不需要额外安装 Node 环境,因为项目直接使用 Thymeleaf 服务端渲染,浏览器打开页面即可。

如果机器上没有装 Maven 的图形环境,也可以在 IDEA 里导入项目的pom.xml自动下载依赖。第一次启动时依赖下载会耗费几分钟,这是正常现象。能用国内镜像仓库的话尽量配一个镜像,能省下不少等待时间。

3.2 初始化数据库:建表语句、测试数据与字符集

在项目根目录的sql/init.sql里,把核心表结构和测试数据准备好。下面这个片段就是典型的三张核心表:用户表、车次表、订单表:

CREATE DATABASE IF NOT EXISTS train_ticket DEFAULT CHARACTER SET utf8mb4; USE train_ticket; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(30), student_no VARCHAR(30), role VARCHAR(10) DEFAULT 'student' ); CREATE TABLE t_train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL, from_station VARCHAR(50) NOT NULL, to_station VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, seat_type VARCHAR(20), price DECIMAL(10,2), total_tickets INT DEFAULT 400, remaining_tickets INT DEFAULT 400 ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, train_id INT NOT NULL, order_no VARCHAR(40) UNIQUE, passenger_name VARCHAR(30), id_card_no VARCHAR(18), ticket_num INT DEFAULT 1, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_train_id (train_id) );

需要注意的是:t_user里的role字段区分管理员和学生;t_order.status我用 0 表示已支付、1 表示已取票、2 表示已退票。订单表里最好给train_id建普通索引,因为查询订单时经常按车次过滤。order_no用唯一索引,避免同一个订单号被重复插入。

初始数据至少要插入几条不同方向的车次,否则页面空空荡荡看不出效果。建议插入北京到上海、北京到广州、上海到杭州三个方向的车次,覆盖高铁和普通车两种类型。

3.3 修改 application.yml:数据库连接参数和端口

项目跑起来之前,先看一眼application.yml,数据库连接信息必须改成你自己本机 MySQL 的配置:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/train_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.train.entity

url里的characterEncoding=utf8一定不能丢,否则插入中文用户名会出现乱码。serverTimezone=Asia/Shanghai是为了让日期时间字段和本地时间对齐。如果 MySQL 用的还是老版本驱动,com.mysql.cj.jdbc.Driver可以替换成com.mysql.jdbc.Driver,不过新版本推荐前者。

3.4 启动项目与第一次点击验证

在项目根目录执行下面命令启动:

mvn spring-boot:run

看到日志中出现Started TrainTicketApplication说明启动成功,然后把浏览器指向http://localhost:8080/login。开发环境里我习惯初始化一个管理员账号和一个学生账号:管理员用户名 admin,密码 admin123;学生用户名 student,密码 123456。登录成功后应该能看到一个可以进行车次查询的搜索表单。

第一次验证只需要做三件事:登录、搜索车次、点“预订”。预订之后到订单列表页确认订单记录出现,并且车次剩余票数减一,这个闭环就算跑通了。

3.5 启动失败时先看这三处

如果启动直接报Failed to configure a DataSource,说明 Maven 没有成功引入数据库连接依赖,或者application.yml里的数据源配置没有被扫描到。顺着target/classes目录确认配置文件是否被编译进去。如果端口冲突,修改server.port换成 8081 即可。这几个小排查点能覆盖大多数第一次启动失败的场景。

4. 核心模块拆解:登录校验、动态车次查询与下单扣余票

4.1 登录拦截器:把 Session 校验做成统一关卡

订票系统里很多页面都需要登录才能访问,最干净的做法不是每个 Controller 里写重复的if (session == null),而是实现一个拦截器统一拦住请求:

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { // 拦截后重定向到登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } // 放行管理员接口时可以做角色校验 return true; } }

这个拦截器的核心思路:每次进入任何受保护 URL 之前先检查 Session 里有没有loginUser。没有就重定向,有就直接放行。如果还要区分管理员角色,可以在preHandle里增加user.getRole()的判断,只允许admin角色访问/admin/**,这是后续扩展权限的好位置。

注册拦截器时有一个细节不可遗漏:静态资源路径和登录页本身必须放行,否则会出现“登录页也被重定向到登录页”的死循环。在InterceptorConfig中这样写:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/images/**"); } }

excludePathPatterns里的路径不会经过拦截器,保证用户未登录时至少能访问登录页面和静态样式。

4.2 车次查询:MyBatis 动态 SQL 解决多条件组合

车次查询是整个订票入口,难点在于用户可能只填起始站,也可能加上日期、座位类型。如果为每种组合写一条固定 SQL,代码会膨胀到没法维护。MyBatis 的动态 SQL 可以把这些条件组合成一条查询:

<select id="selectByCondition" resultType="com.train.entity.Train"> SELECT id, train_no, from_station, to_station, start_time, arrive_time, seat_type, price, total_tickets, remaining_tickets FROM t_train <where> <if test="fromStation != null and fromStation != ''"> AND from_station = #{fromStation} </if> <if test="toStation != null and toStation != ''"> AND to_station = #{toStation} </if> <if test="date != null"> AND DATE(start_time) = #{date} </if> <if test="remainingOnly != null and remainingOnly == true"> AND remaining_tickets &gt; 0 </if> </where> ORDER BY start_time </select>

<where>标签的好处是自动去掉第一个多余的AND。如果用户只填起始站,最终执行的 SQL 就是WHERE from_station = '北京',不会再出现WHERE AND from_station这种语法错误。注意&gt;是 XML 里大于号的标准写法,直接用>会破坏 XML 结构。

这里有个常见误用值得提醒:如果用户没有填日期,却想查“今天以后的车次”,不要直接在 Service 里拼接字符串,而是在 Service 层判断后把startTimeFrom这样的字段塞进 DTO,Mapper 里再用>=查询。这样始终保持 Mapper 的参数结构清晰,后续想增加分页查询也容易。

4.3 下单扣减余票:事务与SELECT FOR UPDATE缺一不可

订票系统最常见的业务事故就是“超卖”:两个用户同时看到剩 1 张票,同时下单,结果两个订单都成功。原因在于查余票和扣余票之间不是原子操作,必须把整段逻辑放进事务,并用行锁锁住那一条车次记录:

@Service public class OrderServiceImpl implements OrderService { @Autowired private TrainMapper trainMapper; @Autowired private OrderMapper orderMapper; @Override @Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 行锁锁定车次记录,防止其他事务同时修改余票 Train train = trainMapper.selectByIdForUpdate(dto.getTrainId()); if (train.getRemainingTickets() < dto.getTicketNum()) { throw new RuntimeException("余票不足,请选择其他车次"); } // 2. 扣减余票 trainMapper.decreaseRemaining(dto.getTrainId(), dto.getTicketNum()); // 3. 生成订单号并插入订单表 Order order = new Order(); order.setUserId(dto.getUserId()); order.setTrainId(dto.getTrainId()); order.setOrderNo(generateOrderNo(train.getTrainNo())); order.setPassengerName(dto.getPassengerName()); order.setStatus(0); orderMapper.insert(order); return order; } private String generateOrderNo(String trainNo) { return trainNo + System.currentTimeMillis(); } }

这段代码有三个关键点。第一,@Transactional(rollbackFor = Exception.class)不只对运行时异常生效,还把普通的Exception也纳入回滚,避免插入订单成功但扣余票失败时数据不一致。第二,selectByIdForUpdate在数据库层加的是行级排他锁,同一时刻只有第一个事务能读到该记录并继续执行,其他事务必须等它提交或回滚。第三,decreaseRemaining里写的是SET remaining_tickets = remaining_tickets - #{count},不要先查出来减完再整体更新,否则又会产生并发覆盖。

对应 Mapper 中selectByIdForUpdate的 SQL 是:

SELECT * FROM t_train WHERE id = #{trainId} FOR UPDATE

注意FOR UPDATE必须放在事务里才有意义,如果事务没有开启,锁会在查询结束后的下一次交互之前自动释放,等于没锁。这是排查并发异常时最先要确认的一点。

4.4 订单状态用简单的 TINYINT 字段管理

订单状态不建议用字符串乱写,我在这个项目里用数字状态映射:0 已支付、1 已取票、2 已退票、3 已改签。Service 层处理退票时,先查订单状态,再恢复车票余量:

@Transactional(rollbackFor = Exception.class) public void refund(Long orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != 0) { throw new RuntimeException("只有已支付订单才能退票"); } orderMapper.updateStatus(orderId, 2); trainMapper.increaseRemaining(order.getTrainId(), order.getTicketNum()); }

这段退票逻辑里的判断条件很关键:不允许对已退票订单重复退票,防止利用业务漏洞把余票刷回来。改签的逻辑类似,相当于一次退票加一次新下单,但要把原订单状态改成已改签而不是已退票,这样财务对账时能追踪到原单。

5. 绕不开的避坑:从能跑到敢改要过的四道坎

5.1 数据库中文乱码:从启动第一次写入就踩

现象:登录注册时写入的中文名字在管理端变成“???”。原因:数据库连接 URL 没有指定characterEncoding=utf8,或者建表时用了默认的 latin1 字符集。解决:把application.yml的连接 URL 改成useUnicode=true&characterEncoding=utf8,并重建数据库,建表语句里统一指定DEFAULT CHARSET utf8mb4。改配置后必须重启应用,已经写入的脏数据需要手动清理。

5.2 余票超卖:并发测试时一测就翻车

现象:用两个浏览器窗口同时下单,最终两个订单都成功,剩余票数变成负数。原因:Service 方法里没有使用SELECT ... FOR UPDATE,或者事务注解失效。解决:先给车次查询加上FOR UPDATE,同时确认@Transactional的类是被 Spring 管理的。如果是自己new出来的 Service 对象,切面根本不会生效。检查日志里是否有Setting autocommit to false,有的话说明事务开始工作了。

5.3 重定向死循环:登录页进不去

现象:访问http://localhost:8080/login时浏览器提示“此页面包含循环重定向”。原因:拦截器没有放行/login路径,同时 Controller 里又把未登录请求重定向到/login,于是登录请求再次被拦截。解决:检查excludePathPatterns是否包含/login,还要注意静态资源路径/css/**、/js/**是否被误拦截。页面加载不出样式,多半是静态资源路径没有放行,观察浏览器的 Network 面板就能看出来。

5.4 日期参数导致接口 400

现象:提交2025-06-01到后台,TrainQueryDTO里的LocalDate字段报转换错误,返回 400。原因:Spring 默认只支持yyyy/MM/dd格式,或者前端传入的格式和 Controller 不一致。解决:在 DTO 字段上增加@DateTimeFormat(pattern = "yyyy-MM-dd")注解:

public class TrainQueryDTO { private String fromStation; private String toStation; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate date; }

同时建议前端日期控件统一输出yyyy-MM-dd格式。如果未来接手机端 App,可以再配置全局的 Jackson 日期格式,避免每个字段都加班注解。

6. 二次开发实战:给订票系统加一个“我的订单”页并顺手完善安全校验

6.1 业务分析:页面要展示哪些订单字段

学生最关心的订单页面,至少需要三类信息:车次基础信息,包括车次号、出发到达站、时间;乘客信息,包括姓名、证件号;订单状态,包括已支付、已退票。同时还需要一个“退票”按钮,只有状态为已支付时才能点击。

6.2 后端实现:按当前用户查询订单

在OrderController里增加一个映射,获取 Session 中的用户 ID,然后调用订单查询服务:

@GetMapping("/my-orders") public String myOrders(HttpSession session, Model model) { User user = (User) session.getAttribute("loginUser"); List<OrderVO> orders = orderService.listOrdersByUserId(user.getId()); model.addAttribute("orders", orders); return "my-orders"; }

OrderVO是一个新增的视图对象,它可以包含trainNo、fromStation、toStation等冗余字段,这些字段需要OrderMapper.xml里用 join 查询拼接出来。常规写法是:

<select id="listOrdersByUserId" resultType="com.train.vo.OrderVO"> SELECT o.order_no, o.passenger_name, o.ticket_num, o.status, t.train_no, t.from_station, t.to_station, t.start_time FROM t_order o JOIN t_train t ON o.train_id = t.id WHERE o.user_id = #{userId} ORDER BY o.create_time DESC </select>

6.3 前端改造:状态中文映射与退票按钮

在my-orders.html里,用 Thymeleaf 循环渲染订单列表,并把 status 数字翻译成中文。关键代码片段:

<tr th:each="order : ${orders}"> <td th:text="${order.trainNo}"></td> <td th:text="${order.fromStation + ' -> ' + order.toStation}"></td> <td th:text="${order.startTime}"></td> <td> <span th:if="${order.status == 0}">已支付</span> <span th:if="${order.status == 2}">已退票</span> </td> <td> <form th:if="${order.status == 0}" th:action="@{/order/refund}" method="post"> <input type="hidden" name="orderId" th:value="${order.id}" /> <button type="submit">退票</button> </form> </td> </tr>

退票请求使用 POST 而不是 GET,因为退票会修改数据,必须走非幂等请求。隐藏字段里带上订单 ID,提交给OrderController.refund。二次开发时很容易犯的一个错误是漏掉th:if="${order.status == 0}"的判断,导致已退票的订单还能再次提交退票请求,所以前端按钮和后端 Service 的判断必须同时存在。

6.4 二次开发的关键验证路径

改完之后不要只点一遍页面就认为完成。我习惯按照“接口层、服务层、数据层”三个顺序验证:先用测试账号登录,确认只能看到自己的订单;再手动改数据库中某个订单状态为 2,刷新页面,确认按钮消失;最后用两个账号同时操作同一笔数据,确保业务逻辑不会互相污染。把 Maven 项目打成 jar 包时执行mvn clean package,然后在服务器上运行java -jar target/train-ticket.jar,生产打包方式也和开发期略有不同,需要多留意配置文件里的数据库地址是否被覆盖。

做过这个改造之后,我对“给老项目加功能”这件事有了一点新习惯:无论多小的改动,先理清 session 里的登录用户是谁、订单状态有哪些合法流转、哪些操作必须放进事务,再看页面怎么写。因为这类系统的业务逻辑并不复杂,出问题的地方往往不在功能实现本身,而在数据一致性和状态边界上。从那以后我再做订票类系统,都会强制走一遍并发下多次下单的测试脚本,确认余票不会变成负数才敢交付。希望这次拆解能帮你在自己的订票系统上少踩几个类似的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询