简介:这是一套基于JavaWeb实现的火车售票系统完整项目资料,面向计算机相关专业正在准备期末大作业、课程设计的学生,以及需要项目实战练习的学习者。项目经导师指导并认可通过,评审分达99分,可作为课程设计或毕业设计的参考方案。压缩包共1290个文件,约33.99MB,涵盖92个Java源文件、27个JSP页面、358个HTML页面、59个CSS样式、196个JavaScript脚本,以及数据库SQL脚本、jar依赖包和图片资源等,前后端结构完整,便于直接部署运行与二次开发。资源中附有数据库文件与文档说明,可帮助读者理解购票、车次查询、订单管理等核心业务模块的实现思路,掌握Servlet、DAO分层与数据库连接等JavaWeb关键技术。目前已有238人学习下载,适合需要快速搭建项目框架、对照调试与查漏补缺的初学者和进阶学习者参考使用。
1. 火车售票系统到底在考什么:从选座到订单,一套 JavaWeb 项目怎么把课设分数拉开
很多人做期末大作业,第一反应是去搜「javaweb项目完整案例mysql」,找到一个能跑的 demo 就交差。但火车售票系统这个题目,老师一眼就能看出你是真做了还是套模板——因为它天然带三个硬骨头:车次与余票的并发扣减、订单状态机、以及座位与车厢的映射关系。这三个点只要有一个没想清楚,答辩时被追问两句就露馅。这套「基于 JavaWeb 的火车售票系统」本质是一个典型的 B/S 架构 CRUD 加强版:前端 JSP 或 HTML 渲染车次列表和下单页,后端 Servlet 或 Spring Boot 处理查询、下单、退票,MySQL 存车次、座位、订单、用户四类核心数据。它适合两类人:一是课程设计想拿良以上的本科生,二是转行练手需要一份能讲清楚业务的项目。下面我按「先立模型、再搭环境、后填业务、最后避坑」的顺序,把能直接抄作业的路径拆开讲。
2. 先把数据模型立住:车次、座位、订单三张表怎么设计才不返工
2.1 为什么表设计错了后面全是补丁
火车售票和普通电商最大的区别是「库存」不是简单一个数字。同一趟车,北京到上海和济南到南京卖的是同一批座位,但区间不同。如果你只在车次表里放一个remain_ticket字段,那济南上车的乘客会把北京到济南的座位也占掉,超卖是必然的。常见做法是把库存拆到「车次 + 座位 + 区间」粒度,但课设规模没必要上那么复杂,我一般用「车次 + 日期 + 座位类型」做库存行,再在订单里记录上车站和下车站,下单时用 SQL 条件判断区间是否冲突。这样既不会超卖,代码量也可控。
核心四张表:train(车次基础信息)、train_schedule(车次每日开行与余票)、seat(座位物理信息,可选)、orders(订单)。下面是我实际用过的建表语句,字段名和类型可以直接抄。
-- 车次基础表:一趟车固定不变的信息 CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT '车次号,如G1024', start_station VARCHAR(32) NOT NULL, end_station VARCHAR(32) NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, UNIQUE KEY uk_train_no (train_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 车次每日开行表:余票按日期和座位类型拆 CREATE TABLE train_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, run_date DATE NOT NULL, seat_type TINYINT NOT NULL COMMENT '1商务 2一等 3二等', price DECIMAL(10,2) NOT NULL, total_seat INT NOT NULL, sold_seat INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_type (train_id, run_date, seat_type), KEY idx_run_date (run_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:记录区间,退票和改签都靠它 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, schedule_id INT NOT NULL, from_station VARCHAR(32) NOT NULL, to_station VARCHAR(32) NOT NULL, seat_type TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已退票 3已改签', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_schedule (schedule_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:train_schedule用(train_id, run_date, seat_type)做唯一键,保证同一天同一车次同一座位类型只有一行库存,扣减时直接UPDATE ... SET sold_seat = sold_seat + 1 WHERE sold_seat < total_seat,靠数据库行锁保证不超卖。orders里存from_station和to_station是为了退票时能精确释放对应区间的库存——如果你的课设不做区间库存,这两个字段也建议留着,答辩时能讲出「为后续扩展预留」。
参数说明:seat_type用 TINYINT 而不是 VARCHAR,省空间且方便做枚举映射;price用 DECIMAL 不用 FLOAT,金额计算不会出现 0.1+0.2 的玄学问题;status用数字而不是字符串,索引效率更高。字符集统一 utf8mb4,避免车站名里有生僻字存不进去。
2.2 用 IDEA 跑通第一个查询接口
环境这块,热搜里「idea运行javaweb项目配置」是高频问题。我一般用 Maven 建项目,Tomcat 9 + JDK 8 或 11,MySQL 5.7 或 8.0 都行。关键是把数据库连接池配好,别用 DriverManager 每次新建连接,课设答辩时老师问「连接池用的什么」你答不上来就尴尬。常见做法是 Druid 或 HikariCP,下面给一个 Druid 的最小配置。
<!-- pom.xml 关键依赖 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency># druid.properties driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/train_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=你的密码 initialSize=5 maxActive=20 maxWait=3000逻辑说明:serverTimezone=Asia/Shanghai不加的话 MySQL 8 会报时区错误,这是新手最常见的翻车点。maxActive=20对课设足够,太大反而浪费。连接池初始化放在ServletContextListener里,项目启动就加载,别每次请求都读配置文件。
参数说明:initialSize是启动时创建的连接数,maxActive是最大连接数,maxWait是获取连接的超时毫秒数。这三个值根据你机器配置调,课设演示并发不高,5/20/3000 是稳妥值。
3. 下单和退票的业务闭环:Servlet 里怎么写才不会被追问到卡壳
3.1 下单接口的完整链路与事务边界
下单是整个系统最容易出 bug 的地方。用户点「提交订单」,后端要做四件事:校验余票、扣减库存、生成订单、返回结果。这四步必须在一个事务里,否则扣了库存没生成订单,或者生成了订单没扣库存,都是脏数据。我一般用 JDBC 手动控制事务,因为课设里用 Spring 声明式事务反而让老师觉得你没理解原理。
public String createOrder(int userId, int scheduleId, String from, String to, int seatType) throws SQLException { Connection conn = null; try { conn = DruidUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 扣减库存,条件里带 sold_seat < total_seat 防止超卖 String deductSql = "UPDATE train_schedule SET sold_seat = sold_seat + 1 " + "WHERE id = ? AND sold_seat < total_seat"; PreparedStatement ps = conn.prepareStatement(deductSql); ps.setInt(1, scheduleId); int affected = ps.executeUpdate(); if (affected == 0) { conn.rollback(); return "余票不足"; } // 2. 生成订单 String orderNo = "T" + System.currentTimeMillis() + userId; String insertSql = "INSERT INTO orders(order_no,user_id,schedule_id,from_station,to_station,seat_type,amount,status) " + "VALUES(?,?,?,?,?,?,?,0)"; PreparedStatement ps2 = conn.prepareStatement(insertSql); ps2.setString(1, orderNo); ps2.setInt(2, userId); ps2.setInt(3, scheduleId); ps2.setString(4, from); ps2.setString(5, to); ps2.setInt(6, seatType); ps2.setBigDecimal(7, new BigDecimal("553.00")); // 实际应从 schedule 查价格 ps2.executeUpdate(); conn.commit(); return orderNo; } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }逻辑说明:UPDATE ... WHERE sold_seat < total_seat这一句是防超卖的核心,数据库会对这一行加排他锁,并发请求会串行执行,第二个请求发现sold_seat已经等于total_seat就更新 0 行,直接回滚。这比先SELECT再UPDATE可靠得多,后者在并发下必然超卖。订单号用时间戳加用户 ID,课设够用,生产环境要用雪花算法。
参数说明:setAutoCommit(false)开启事务后,所有操作要么全成功要么全回滚。conn.rollback()在库存不足和异常时都要调用。finally里恢复自动提交并关闭连接,否则连接池里的连接会一直挂着事务。
3.2 退票与状态流转:别让订单状态变成黑匣子
退票不是简单把status改成 2,还要把库存加回去。而且只有「已支付」的订单能退,「待支付」的应该走取消而不是退票。我一般把状态流转写成枚举,避免代码里到处是魔法数字。
public enum OrderStatus { PENDING(0, "待支付"), PAID(1, "已支付"), REFUNDED(2, "已退票"), CHANGED(3, "已改签"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }退票的 SQL 要同时更新订单和库存,同样放在一个事务里:
-- 退票:先查订单状态,再更新 UPDATE orders SET status = 2 WHERE id = ? AND status = 1; -- 如果 affected > 0,说明退票成功,再释放库存 UPDATE train_schedule SET sold_seat = sold_seat - 1 WHERE id = ? AND sold_seat > 0;逻辑说明:WHERE status = 1保证只有已支付订单能退,重复退票第二次会更新 0 行,天然幂等。库存释放加sold_seat > 0防止减成负数。这两句必须在同一事务,否则退票成功但库存没释放,余票就少了。
参数说明:订单 ID 和 schedule ID 从订单记录里取,不要相信前端传的 schedule ID,否则用户改个参数就能退别人订单的库存。
4. 避坑与排查:课设答辩前一定要自己走一遍的五个坑
4.1 中文乱码:从 JSP 到数据库全链路排查
现象:车次列表里车站名显示成问号或乱码。原因:JSP 页面、Servlet 响应、数据库连接、表字符集四处只要有一处不是 UTF-8 就会乱。解决:JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" %>,Servlet 里response.setContentType("text/html;charset=UTF-8"),连接 URL 带characterEncoding=utf8,建表用utf8mb4。四处对齐后基本不会再乱。
4.2 余票显示不对:sold_seat 和实际订单数对不上
现象:页面显示余票 10,但实际下单 8 次就提示没票了。原因:测试时手动改过数据库,或者退票时库存没加回去。解决:写一个对账 SQL,SELECT schedule_id, COUNT(*) FROM orders WHERE status = 1 GROUP BY schedule_id,和sold_seat比对,不一致就说明有脏数据。课设演示前跑一遍,心里有底。
4.3 连接池耗尽:页面卡死最后报 500
现象:连续点几次下单后整个系统无响应。原因:Connection 没关闭,或者事务没提交导致连接被占用。解决:所有数据库操作放 try-finally,finally里关闭连接;事务里异常必须 rollback。用 Druid 的话可以在监控页面看活跃连接数,超过 maxActive 就是泄漏了。
4.4 日期类型转换报错:String 和 Date 互转翻车
现象:查询某天车次时后台抛java.text.ParseException。原因:前端传的是2024-01-01字符串,后端直接往 SQL 里拼,或者 SimpleDateFormat 格式不匹配。解决:统一用java.sql.Date.valueOf("2024-01-01")转换,或者用 PreparedStatement 的setDate方法。别用字符串拼接 SQL,既有时区问题又有注入风险。
4.5 答辩被问「并发怎么处理」答不上来
现象:老师问「两个人同时买最后一张票会怎样」。原因:只写了业务代码没想过并发。解决:把 3.1 里那句UPDATE ... WHERE sold_seat < total_seat讲清楚,说明数据库行锁保证了原子性,再补一句「如果压力大可以加 Redis 预扣减,但课设规模数据库足够」。这样答既展示了原理又体现了边界意识。
5. 让课设多拿五分的两个进阶技巧:区间库存与订单超时
区间库存是火车售票和普通商城最大的区别,也是答辩时最能体现你思考深度的点。前面表设计里orders存了from_station和to_station,但库存还是按整个车次扣的。如果你想再进一步,可以把train_schedule拆成「车次 + 日期 + 区间」的库存行,下单时只扣对应区间的库存。实现方式是在查询余票时用WHERE from_station = ? AND to_station = ?,扣减时同样带区间条件。代价是数据量变大,但课设车次少,完全撑得住。我一般会在答辩时主动提这个扩展点,老师会觉得你想过真实场景。
订单超时取消是另一个加分项。待支付订单如果一直不支付,库存就被占着。常见做法是下单时记create_time,起一个定时任务每分钟扫一次,把超过 15 分钟且status = 0的订单改成取消并释放库存。用ScheduledExecutorService就能实现,不需要引入 Quartz 这种重框架。
// 简易超时取消任务,项目启动时调用 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { String sql = "UPDATE orders SET status = 4 WHERE status = 0 " + "AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE)"; // 执行更新后,再批量释放对应库存,此处省略具体 JDBC 代码 }, 0, 1, TimeUnit.MINUTES);逻辑说明:status = 4表示已取消,和退票区分开。定时任务每分钟跑一次,对课设来说频率足够。释放库存时要根据订单里的schedule_id逐条减回去,注意加sold_seat > 0保护。这个功能代码量不大,但演示时能讲出「库存时效性」这个概念,比单纯 CRUD 高一个层次。
参数说明:INTERVAL 15 MINUTE是超时时间,可以按需改成 30 分钟。scheduleAtFixedRate的第二个参数是首次延迟,第三个是间隔。注意定时任务里的数据库操作也要关连接,否则跑一晚上连接池就满了。
最后说个血泪经验:课设文档说明别最后一天才写。我一般边写代码边把关键 SQL 和接口说明记在 Markdown 里,最后直接导出成 Word。文档里重点写「为什么这么设计」而不是「这个类有什么方法」,老师看的是你的思路不是 API 手册。数据库脚本、源码、文档三样对齐,演示前自己从注册到下单到退票完整走一遍,基本就稳了。希望帮到你。
本文还有配套的精品资源,点击获取