Java学生火车票订票系统:Spring Boot+MyBatis+MySQL课设实战指南
简介面向毕业设计、课程设计及实际项目开发场景一个基于Java实现的学生火车票订票系统提供了完整可运行的工程项目采用IDEA 2016.3、SQL Server 2014和JavaFX技术栈核心功能涵盖学生信息管理、车票目的地与票价记录、在线订票、退票、信息统计查询以及操作员管理并支持按姓名查找订单等细节操作。资源包共72个文件压缩后约8.87MB包括9个Java源码文件、16个XML配置、15个JAR依赖库、11张PNG运行截图以及数据库课设报告、PDF参考论文、README说明和构建脚本目录结构紧凑源码、配置与文档分层清晰。目前已有40人学习下载适合需要完成课程设计、准备毕业答辩或快速搭建订票类系统的Java学习者。项目内附数据库课设报告与项目开发文档经过严格测试可在理解业务逻辑的基础上修改界面、扩展功能或调整数据库减少从零开发的工作量。1. 学生火车票订票系统为什么这道课设题最考验 Java 基本功每年毕业设计和课程设计季总有一批人扎进同一个题目里基于 Java 的学生火车票订票系统。这个题目看起来很常规无非是查车次、下订单、退票但它恰好把 Web 开发里最容易翻车的几个点全占了——登录状态怎么保持、库存类数据怎么在并发下不超卖、订单状态怎么流转。很多同学写到一半发现「明明按教程敲的老师一演示就出问题」问题基本都出在需求没拆清楚就急着写代码。这篇文章按 Spring Boot MyBatis MySQL 这条最稳妥的路线把设计、编码、避坑和答辩验证串起来讲新手能照着复现熟手也能直接拿去当课设骨架。2. 从用例到表结构学生、车次、订单三张表怎么设计标题里写的是「学生火车票订票系统」但很多实现做着做着就变成普通火车票系统学生身份只拿来登录。实际上「学生」这个限定直接影响需求边界学生订票通常有票价优惠、学号认证、假期车次筛选这些点。课设不要求做成 12306但把「学生」这个身份在数据模型里体现出来是评审老师判断你有没有认真读题的第一个信号。2.1 学生、车次、订单先把业务边界画清楚系统里至少有两类用户学生和管理员。学生的操作是注册、登录、按条件查车次、提交订单、退票、查看自己的订单管理员负责维护车次信息、查看全部订单。这里不建议做「管理员也能订票」这种混合角色页面和接口都要分开答辩时被问到权限设计会好解释得多。车次对象要注意一个细节同一趟列车在不同日期会重复出现。比如 G101 次每天都有但每天的可售票记录其实是独立的一条数据。所以车次表在设计时要把「发车日期」直接作为一个字段放进去而不是只存车次号加一个时刻表。否则你查「明天从北京到上海的车」时SQL 会写得很别扭。订单是这个系统的核心。订单表除了关联学生和车次还要把下单时的车次号、发车日期、发车时间、票价、座位类型这些信息冗余一份进去。原因是管理员后续可能修改车次价格甚至停运某趟车如果订单只存外键历史订单的显示就会跟着变这是典型的「快照」设计思路。课设论文里写上「订单信息做快照冗余保证历史数据可追溯」比堆十页框架介绍有用。2.2 表结构设计五张表和一个状态字段按上面的边界一个能跑通的课设版本通常需要五张表学生表、管理员表、车次表、订单表再加一个可选的系统配置表。车站表只在你要做多经停站查询时才需要否则用「起始站、终点站」两个字段就够了别自己给自己加工作量。学生表里username 要做唯一索引学号 student_no 也建议建唯一索引因为学号是比自增主键更自然的业务标识。密码字段在课设里用 MD5 加密存储就够了想加分可以换成 BCrypt但对应要引入 Spring Security样板代码会多不少自己权衡。手机号、真实姓名这些字段是做表单校验用的没有它们注册页面就少了可写的内容。车次表是查询和余票控制的中心。total_seats 表示初始化时录入的总座位数remaining_seats 表示当前剩余座位数每成功生成一笔订单就把 remaining_seats 减一退票就加一。有人习惯用「总座位数减去有效订单数」实时算余票这在数据量小的时候没问题但一旦有退票、取消、异常订单统计口径就会乱。余票字段直接落到表里配合事务控制是课设阶段最不容易错的做法。订单表最关键的字段是 status它控制整条链路。常见的状态值就四个0 待支付、1 已出票、2 已退票、3 已取消。很多同学把状态设计成字符串什么「已支付」「已退」「作废」都往里写后面做统计时 GROUP BY 出来的结果五花八门。用 TINYINT 加注释维护一组固定枚举代码里再建对应的常量类这是 Java 基础里「见名知意」的直接体现。2.3 建表 SQL 与初始化数据一份能直接跑的脚本下面是按 MySQL 8.0 写的建表语句字符集用 utf8mb4避免中文乱码。字段注释写清楚导入后直接用。-- 学生表 CREATE TABLE student ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT 密码MD5密文, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, student_no VARCHAR(20) NOT NULL COMMENT 学号, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表; -- 车次表 CREATE TABLE train ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, train_no VARCHAR(20) NOT NULL COMMENT 车次号, start_station VARCHAR(50) NOT NULL COMMENT 始发站, end_station VARCHAR(50) NOT NULL COMMENT 终点站, depart_date DATE NOT NULL COMMENT 发车日期, depart_time TIME NOT NULL COMMENT 发车时间, arrive_time TIME NOT NULL COMMENT 到达时间, seat_type VARCHAR(20) NOT NULL DEFAULT 硬座 COMMENT 座位类型, price DECIMAL(10,2) NOT NULL COMMENT 票价, total_seats INT NOT NULL COMMENT 总座位数, remaining_seats INT NOT NULL COMMENT 剩余座位数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可售 0停运, PRIMARY KEY (id), KEY idx_depart_date (depart_date), KEY idx_train_no (train_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车次表; -- 订单表 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 订单号, student_id BIGINT NOT NULL COMMENT 学生ID, train_id BIGINT NOT NULL COMMENT 车次ID, train_no VARCHAR(20) NOT NULL COMMENT 车次号快照, depart_date DATE NOT NULL COMMENT 发车日期快照, depart_time TIME NOT NULL COMMENT 发车时间快照, seat_type VARCHAR(20) NOT NULL COMMENT 座位类型快照, price DECIMAL(10,2) NOT NULL COMMENT 票价快照, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已出票 2已退票 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 下单时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_student_id (student_id), KEY idx_train_id (train_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;SQL 里几个关键点解释一下。orders 表把车次号、日期、时间、票价、座位类型全部冗余进来了这就是前面说的快照设计订单生成后车次表怎么改都不影响历史订单展示。train 表的 remaining_seats 是余票控制的核心字段后面写订票事务时就要锁它。索引都建在查询条件最常用的列上depart_date 和 train_no 组合起来就是「查某天某车次」的常用场景。初始化数据不要偷懒只建表不灌数据否则项目跑起来页面上空荡荡。至少准备 5 个学生账号、10 趟覆盖今天和明天的车次密码统一用 MD5 加密后的固定值方便测试登录。3. 用 Spring Boot MyBatis 搭后端分层、映射与订票主流程表结构定下来后后端骨架的搭建反而变得机械。现在的课设主流路线是 Spring Boot 做容器MyBatis 做持久层MySQL 存数据前端用 Thymeleaf 模板或者简单的 HTML AJAX。这套组合最实际的好处是资料多、排错容易随便搜一个报错信息都能找到对应解决方案。3.1 Maven 项目结构与依赖骨架搭对少走弯路项目结构沿用 Maven 的单模块标准布局就行不需要拆多模块课设拆多模块只会让你自己绕晕。核心分包是 controller、service、mapper、entity、config 这五层对应关系清晰答辩时讲架构也讲得出东西。pom.xml 里引入下面几个依赖注意把 MySQL 驱动的 scope 设成 runtime版本号用 Spring Boot 父工程统一管理。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependenciesMyBatis 的 starter 版本要单独指定因为 Spring Boot 父工程不管理它。2.3.1 对应 Spring Boot 2.7.x往上兼容没问题。如果你用 Spring Boot 3.x就把 starter 换成 3.x 版本同时注意 MySQL 驱动坐标名已经从 mysql-connector-java 变成了 mysql-connector-j。3.2 实体类与 Mapper 映射MyBatis 的参数绑定细节实体类和数据库字段一一对应。Java 属性用驼峰命名数据库字段用下划线MyBatis 里开启 map-underscore-to-camel-case 后可以自动映射不用写一堆 resultMap。下面是 Train 实体类的核心字段public class Train { private Long id; private String trainNo; private String startStation; private String endStation; private LocalDate departDate; private LocalTime departTime; private LocalTime arriveTime; private String seatType; private BigDecimal price; private Integer totalSeats; private Integer remainingSeats; private Integer status; // getter/setter 省略 }日期类型用 java.time 包下的 LocalDate 和 LocalTime不要再用 java.util.Date。LocalDate 对应数据库的 DATELocalTime 对应 TIMEMyBatis 3.4 以上原生支持不需要额外类型处理器。Mapper 接口和 XML 是 MyBatis 的核心用法。接口里定义查询方法XML 里写 SQL两者通过方法的全限定名绑定。下面这段是根据日期和区间查询车次的接口public interface TrainMapper { ListTrain searchTrains(Param(departDate) LocalDate departDate, Param(startStation) String startStation, Param(endStation) String endStation); }select idsearchTrains resultTypecom.example.train.entity.Train SELECT id, train_no, start_station, end_station, depart_date, depart_time, arrive_time, seat_type, price, total_seats, remaining_seats, status FROM train WHERE status 1 if testdepartDate ! null AND depart_date #{departDate} /if if teststartStation ! null and startStation ! AND start_station LIKE CONCAT(%, #{startStation}, %) /if if testendStation ! null and endStation ! AND end_station LIKE CONCAT(%, #{endStation}, %) /if ORDER BY depart_date, depart_time /selectXML 里三个 if 标签实现了动态 SQL参数没传就跳过对应条件。这里用 #{departDate} 而不是 ${departDate}是参数占位符MyBatis 会帮我们处理成预编译的 ?从根上防住 SQL 注入这也是 java 基础面试里经常被问到的点。LIKE 查询用 CONCAT 拼接 %不要直接写 %${keyword}%那是典型的注入漏洞写法。3.3 服务层与控制层订票主流程的最短实现服务层负责业务逻辑订票是核心方法。逻辑拆成三步校验车次存在且有余票扣减余票生成订单。三步必须在一个事务里否则会出现余票扣了但订单没生成的情况。先看控制层的入口RestController RequestMapping(/api/order) public class OrderController { Resource private OrderService orderService; PostMapping(/create) public Result createOrder(RequestBody OrderCreateDTO dto, SessionAttribute(student) Student student) { if (student null) { return Result.error(请先登录); } Order order orderService.createOrder(student.getId(), dto.getTrainId()); return Result.success(order); } }这里用 SessionAttribute 拿登录时放进去的学生对象拿不到就说明没登录直接拦掉。这个方案的优点是简单直接课设阶段完全够用。OrderCreateDTO 是前端传过来的请求体只包含 trainId 和可能的学生优惠标记不要让它直接绑定数据库实体避免前端乱传字段覆盖服务端数据。服务层的实现是这样的Service public class OrderService { Resource private TrainMapper trainMapper; Resource private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Order createOrder(Long studentId, Long trainId) { // 锁行读取车次防止并发超卖 Train train trainMapper.selectByIdForUpdate(trainId); if (train null || train.getStatus() ! 1) { throw new RuntimeException(车次不存在或已停运); } if (train.getRemainingSeats() 0) { throw new RuntimeException(余票不足); } // 扣减余票 int updated trainMapper.decreaseRemainingSeats(trainId); if (updated ! 1) { throw new RuntimeException(余票扣减失败); } // 生成订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setStudentId(studentId); order.setTrainId(train.getId()); order.setTrainNo(train.getTrainNo()); order.setDepartDate(train.getDepartDate()); order.setDepartTime(train.getDepartTime()); order.setSeatType(train.getSeatType()); order.setPrice(train.getPrice()); order.setStatus(0); orderMapper.insert(order); return order; } }createOrder 方法上加了 Transactional(rollbackFor Exception.class)默认的 rollbackFor 只捕获 RuntimeException这里显式声明成 Exception 更保险。selectByIdForUpdate 是 MyBatis 里对应 SELECT ... FOR UPDATE 的语句会在数据库层面锁住这行车次记录后面的减余票和生成订单串行执行。课设阶段用行锁解决超卖足够了不用引入 Redis 分布式锁。generateOrderNo 一般用时间戳加随机数实现保证唯一。4. 把订票查询和退票跑通登录、余票控制与事务边界骨架搭好之后剩下的就是把各个页面能点的按钮都填上实在逻辑。这一章只讲最核心的四件事登录状态从哪里来、车次查询怎么避免脏数据、订票事务的边界划在哪、退票和支付状态怎么联动。这四个点覆盖了学生端和管理员端的绝大多数接口。4.1 登录与权限控制Session 拦截器怎么加登录接口本身没什么特别的校验用户名密码、把学生对象塞进 Session关键是登录之后的保护逻辑。不加拦截器的话随便一个没登录的请求都能访问订票接口这在验收时是低级错误。常见做法是实现一个 HandlerInterceptor在 WebMvcConfigurer 里注册并指定要拦截的路径。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object student session.getAttribute(student); if (student null) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } return true; } }拦截器里校验的是 Session 中的 student 对象是否存在不存在直接返回 401 JSON不再往下走 Controller。注册拦截器时要注意排除登录、注册、静态资源这些路径否则页面还没打开就被拦住了。管理员路径单独注册一个管理员拦截器或者做角色判断别把两个角色混在一个拦截器里。4.2 车次查询与余票判空SQL 里最容易出错的边界查询接口是最容易被忽略性能问题的接口。课设数据量小怎么写都能跑但要养成两个习惯一是只在必要的列上建索引二是不要 SELECT *。前面建表时已经对 depart_date 建了索引查询走索引避免全表扫描。车次查询的另一个坑是日期边界。前端传一个 2024-01-15后端如果把它转成字符串去比较 DATE 列容易出现时区或格式问题。正确做法是 Controller 层直接用 DateTimeFormat 把字符串转成 LocalDate再传给 Mapper 的 #{departDate} 参数由 MySQL 驱动完成类型转换。余票判断不能只看查询结果。查询到 remaining_seats 是 1但另一笔订单可能在同一毫秒把这张票买走了。所以订票的余票判断和扣减放在同一个事务里用 UPDATE 语句的返回值判断是否真的扣减成功而不是先 SELECT 再 UPDATE。这是数据库事务隔离级别下的经典实践。4.3 订票退票事务为什么 Transactional 不能乱加退票逻辑和订票相反订单状态从已出票改成已退票车次余票加一。这里最容易被忽视的是事务边界。假设退票方法里只更新了订单表没更新车次表的余票就会出现「票退了但余票没回来」的 bug。反过来如果余票先加回来订单状态更新失败那余票就虚增了。这两个操作必须在一个事务里。Transactional(rollbackFor Exception.class) public void refundOrder(Long orderId, Long studentId) { // 只能退自己的票 Order order orderMapper.selectByIdAndStudentId(orderId, studentId); if (order null) { throw new RuntimeException(订单不存在); } if (order.getStatus() ! 1) { throw new RuntimeException(当前订单状态不可退票); } // 订单状态改为已退票 orderMapper.updateStatus(orderId, 2); // 余票加回 int updated trainMapper.increaseRemainingSeats(order.getTrainId()); if (updated ! 1) { throw new RuntimeException(余票恢复失败); } }这个方法里先查订单校验订单状态再改状态和恢复余票。把 updateStatus 和 increaseRemainingSeats 放在同一个 Transactional 事务里任何一个抛异常两个操作都会回滚。注意 selectByIdAndStudentId 带了 studentId 条件天然防止学生 A 退学生 B 的票。调用 updateStatus 时不要直接传新状态要在 SQL 里加一层WHERE status 1用更新行数判断是否被并发操作抢先修改这是乐观锁的思路。课设阶段能说出这个逻辑面试官对你的「java 八股文」印象分会明显不一样。5. 课设避坑手册评审老师最爱揪的五个问题每次答辩都能看到有人现场翻车。问题不在代码写不出来而是细节没处理好。这一章把最常见的五个坑按「现象 → 原因 → 解决」写清楚每一条都是可以直接照着改的。5.1 页面中文全部变成问号现象启动项目后打开网页所有中文都是问号或者往数据库插入中文记录时报 Incorrect string value。原因数据库表用了 latin1 或 utf8 字符集或者 JDBC 连接串里没有指定 characterEncoding。MySQL 8.0 默认字符集虽然是 utf8mb4但你手动建表时如果没指定 CHARSET就可能沿用了服务器默认的旧字符集。解决建表语句统一加上DEFAULT CHARSETutf8mb4JDBC 连接串里显式加characterEncodingutf8。MySQL 8.0 以上的驱动连接串写成jdbc:mysql://localhost:3306/train_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai就行。5.2 订票成功后余票没减少现象前端提示订票成功订单表也插入了记录但车次列表里的余票数还是原来的值。原因订票事务只执行了 insert 订单忘了 update 车次表的 remaining_seats。或者两个操作没在同一个事务里insert 成功了update 失败但没有触发回滚。解决把扣减余票和生成订单放进同一个 Transactional 方法先扣余票再生成订单。如果先插入订单再扣余票扣减失败时订单已经写进日志回滚起来容易混乱。测试时可以在 update 语句那里故意写一个不存在的条件让返回值变成 0看整体是否回滚。5.3 并发下单把余票卖成负数现象用两个浏览器同时抢最后一张票两个请求都提示成功余票变成 -1。原因查询余票和扣减余票是两条独立 SQL没有加锁也没有事务隔离。两个请求同时读到 remaining_seats1各自执行扣减最后结果就是 0 或负数。解决查询车次时使用 SELECT ... FOR UPDATE 加行锁或者把扣减 SQL 写成UPDATE train SET remaining_seats remaining_seats - 1 WHERE id ? AND remaining_seats 0然后判断 update 返回的影响行数。影响行数等于 1 才说明扣减成功等于 0 说明没票了直接返回余票不足。这个写法不需要显式加锁更简洁。5.4 Maven 打包后提示找不到 Mapper XML现象本地 IDE 里运行正常用mvn package打成 jar 包后启动报 Invalid bound statement (not found)。原因MyBatis 的 Mapper XML 放在 src/main/java 目录下Maven 默认只打包 .class 文件XML 资源没有被复制到 target/classes 里。解决在 pom.xml 的 build 标签里配置 resources把 src/main/java 下的 XML 也纳入打包范围。或者更规范的做法把 Mapper XML 放到 src/main/resources/mapper 目录然后在 application.yml 里配置mybatis.mapper-locations: classpath:mapper/*.xml。推荐后者干净整洁不用动 Maven 配置。5.5 订单状态被重复更新现象学生连续点了两次退票按钮第二次也提示成功但订单状态已经是已退票了。原因退票方法里拿到订单后只判断了一次状态然后执行 update 时没有带上状态条件。两个请求同时进来都通过校验都执行了更新。解决update SQL 里加上状态条件比如UPDATE orders SET status 2 WHERE id ? AND status 1返回 0 就是已经操作过了。业务层再根据返回行数给前端提示「该订单已退票请勿重复操作」。同样的思路也适用于支付回调、订单取消这些场景。6. 从能跑到能答辩三个验证动作让系统更可信系统功能写完距离「能交」还差一步。课设最尴尬的瞬间是演示时点了两下就报错或者老师问「这个余票会不会超卖」时你只能回答「应该不会」。这章分享三个我自己答辩前必做的验证动作全是低成本高回报的实操。第一个动作是压一压订票接口。不用上 JMeter 那么重的工具写一个简单的循环脚本并发调用订票接口就行。如果你的系统是用浏览器手动点永远发现不了超卖问题。用 IDEA 里的 HTTP Client或者干脆写一个 Java 线程池去请求二十个线程同时抢同一趟车的票跑完看订单数和 remaining_seats 是否对上。这个验证结果写进论文的测试章节比截图十张页面更有说服力。第二个动作是给关键操作留日志。在登录、订票、退票三个入口各打一条业务日志包含操作人、操作时间、订单号、结果。答辩时老师问你「某笔订单是什么时候下的」你打开日志文件按订单号一搜就出来。很多同学答辩紧张被问到细节就容易卡壳有日志在手所有问题都有据可查。日志用 Slf4j 的 Logger 就行不用引入额外组件。第三个动作是准备一套演示数据。把演示日期固定成今天确保页面一打开就有 6 趟以上的可选车次。设计一条完整的演示路径学生登录 → 按日期查车 → 下单 → 查看订单 → 退票 → 管理员登录 → 看到这笔退票记录。把每一步的预期结果写在纸上演示前自己走两遍。你会发现很多平时没注意的边界问题比如退票后订单还显示在待支付列表里。做完这三个动作系统基本就能扛住一轮完整验收。我的习惯是在项目根目录放一个 README.md写清楚启动步骤、默认账号、数据库初始化脚本的位置这样老师打开项目不用问你「怎么跑」省去很多解释成本。希望这个题目能成为你 Java 旅程里一个扎实的里程碑也希望这些踩坑记录能帮你少熬夜一次通过希望帮到你。本文还有配套的精品资源点击获取