资讯动态

基于Spring Boot+MyBatis的学生火车票订票系统实战拆解

发布时间:2026/10/21 2:44:59 来源:尧图企业网站定制
简介一套基于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上海date2025-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 11Spring 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.train.entityurl里的characterEncodingutf8一定不能丢否则插入中文用户名会出现乱码。serverTimezoneAsia/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 idselectByCondition resultTypecom.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 testfromStation ! null and fromStation ! AND from_station #{fromStation} /if if testtoStation ! null and toStation ! AND to_station #{toStation} /if if testdate ! null AND DATE(start_time) #{date} /if if testremainingOnly ! null and remainingOnly true AND remaining_tickets gt; 0 /if /where ORDER BY start_time /selectwhere标签的好处是自动去掉第一个多余的AND。如果用户只填起始站最终执行的 SQL 就是WHERE from_station 北京不会再出现WHERE AND from_station这种语法错误。注意gt;是 XML 里大于号的标准写法直接用会破坏 XML 结构。这里有个常见误用值得提醒如果用户没有填日期却想查“今天以后的车次”不要直接在 Service 里拼接字符串而是在 Service 层判断后把startTimeFrom这样的字段塞进 DTOMapper 里再用查询。这样始终保持 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 没有指定characterEncodingutf8或者建表时用了默认的 latin1 字符集。解决把application.yml的连接 URL 改成useUnicodetruecharacterEncodingutf8并重建数据库建表语句里统一指定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); ListOrderVO orders orderService.listOrdersByUserId(user.getId()); model.addAttribute(orders, orders); return my-orders; }OrderVO是一个新增的视图对象它可以包含trainNo、fromStation、toStation等冗余字段这些字段需要OrderMapper.xml里用 join 查询拼接出来。常规写法是select idlistOrdersByUserId resultTypecom.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 /select6.3 前端改造状态中文映射与退票按钮在my-orders.html里用 Thymeleaf 循环渲染订单列表并把 status 数字翻译成中文。关键代码片段tr th:eachorder : ${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} methodpost input typehidden nameorderId th:value${order.id} / button typesubmit退票/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 里的登录用户是谁、订单状态有哪些合法流转、哪些操作必须放进事务再看页面怎么写。因为这类系统的业务逻辑并不复杂出问题的地方往往不在功能实现本身而在数据一致性和状态边界上。从那以后我再做订票类系统都会强制走一遍并发下多次下单的测试脚本确认余票不会变成负数才敢交付。希望这次拆解能帮你在自己的订票系统上少踩几个类似的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑