简介:基于Java技术栈的车票预订管理系统项目,面向Java Web初学者及需要完成课设、毕设的学生,也适合作为项目实训阶段的进阶练习案例。项目整合Servlet、JSP与MySQL数据库,完整覆盖前端页面展示、后端业务处理及数据持久化等Web开发核心环节,围绕车次信息、用户信息、订单数据设计数据表与交互流程,能帮助理解三层架构和典型业务闭环。压缩包约55.82MB,内含1337个文件,以JSP页面、Java类、CSS样式、JavaScript脚本和SQL脚本为主,同时附有视频讲解,可引导完成环境准备、项目导入、数据库连接等操作。目前已有1105人学习下载。通过实操可掌握JDBC数据增删改查、Servlet请求响应处理、JSP表单交互等技能,也可参考项目的代码组织与数据库设计思路,在此基础上快速搭建和二次开发自己的车票预订功能模块。
1. 一套车票预订系统为什么值得认真做:从毕设选题到可交付方案
每年到了选题季,总有人纠结做什么系统。如果方向是 Java Web,车票预订信息系统几乎是性价比最高的选择之一。它不像电商系统那样业务臃肿,也不像图书管理那样过于单薄——刚好覆盖用户登录、车次查询、余票判断、在线预订、订单管理、后台车次维护这几条完整链路,难度适中,又有足够的设计深度。另一个现实理由是,这套系统的业务逻辑很贴近真实场景,答辩时能讲的东西多:事务怎么处理、并发下单怎么防超卖、会话失效怎么拦截,每一个点都是评委感兴趣的地方。
这份方案从需求定义开始,到数据库设计、核心模块实现、常见坑的排查,最后落到验收和答辩,完整给你一条可以直接参考的落地路径。无论是用来交毕业设计,还是要快速搭一套能演示的车票预订Demo,这套设计都能直接改着用。
2. 需求分析与功能规划:角色、流程与边界
2.1 用户角色与核心用例划分
车票预订系统的角色划分通常不需要太复杂,两类角色足够覆盖全部业务。普通用户负责注册、登录、查询车次、预订车票、查看订单、退票;管理员负责车次信息维护、票价调整、订单查看和基础统计。关键点在于:用户侧的每个操作都要有状态流转,而不是简单的增删改查。
具体到用例,用户侧的「预订车票」是最核心的场景——用户选择出发地、目的地、出发日期,系统展示符合条件的车次和余票数,用户选定车次后提交订单,订单状态从未支付变为已支付,最后出票。退票则是反向状态流转,已支付的订单退掉后,车次余票要回补。管理员侧的车次管理则是「增删改查 + 余票初始化」。
通常这个业务边界就够用了。不要额外加会员积分、优惠券这类功能,每加一个模块,数据库设计、页面、测试用例都会成倍增加,而且和车票预订的核心链路关系不大。毕设的评分逻辑是「核心功能完成度高」优于「功能数量多但都做得浅」。
2.2 核心业务流程:预订和退票的状态机
预订流程决定了系统的数据流向,值得先画清楚再写代码。一个完整的预订流程是这样走的:
- 用户输入出发地、目的地、出发日期,系统查询符合条件的车次列表
- 用户点击某一车次的「预订」,系统校验该车次当日余票是否大于 0
- 余票充足则生成订单,状态为「待支付」,此时不扣减余票
- 用户模拟支付(或直接确认),状态改为「已支付」,同时余票减 1
- 出票成功,订单状态变为「已完成」
这里有一个容易被忽略的设计细节:余票扣减的时机。有些方案在生成订单时就扣余票,用户不支付就会占着票;有些方案在支付时才扣,但如果两个用户同时支付最后一张票,就会超卖。这个矛盾在第 5 章的并发避坑里会专门展开,但数据库设计层面要为它做好准备——订单表和车次表要能支持「事务 + 行锁」的方式。
退票流程相对简单:已支付订单发起退票 → 校验订单状态和当前时间 → 订单状态改为「已退票」→ 对应车次当日余票加 1。
2.3 非功能性需求:并发、安全与响应边界
除了功能列表,还要定义几个非功能指标,否则验收时说不清楚。首次加载车次列表的响应时间一般控制在 2 秒内,查询操作 1 秒内完成;同时在线用户数按 100 人来设计;密码不能明文存数据库,至少用 MD5 加盐;用户未登录不能访问订单和预订接口。
这些指标不需要真的压测到 100 并发,但要在设计文档里写明「系统基于连接池和事务机制,可支撑百级并发下的正常预订」。有了这层表述,答辩时「系统的性能设计」就不再是空话。
3. 总体架构设计:三层结构与六张数据表的职责划分
3.1 JSP + Servlet + JavaBean 的三层职责边界
技术栈选型直接沿用经典的 JSP + Servlet + JavaBean 三层结构,这也是这类系统最稳妥的方案。JSP 只负责展示,页面里通过 JSTL 和 EL 表达式渲染数据,不写 Java 业务代码;Servlet 只做请求接收、参数校验、调用业务逻辑、跳转页面,不在里面拼 SQL;JavaBean 拆成实体类和数据访问类两层。
很多人在这个阶段把代码写乱,根本原因是 Servlet 里既写了 JDBC 又处理了页面跳转。我一般会在项目里做一个强制约定:Servlet 的代码量控制在 100 行以内,超过就说明业务逻辑没有下沉。业务逻辑放到独立的 Service 类里,数据访问统一走 DAO 层,DAO 返回的是实体对象或 List 集合,不直接暴露 ResultSet。
// 数据访问层的接口设计示例 public interface OrderDao { int insert(Order order); // 生成订单 int updateStatus(int orderId, int status); // 更新订单状态 Order findById(int orderId); // 查询单条订单 List<Order> findByUserId(int userId); // 查询用户的订单列表 }接口先行有几个实际好处。第一,Service 层依赖接口而不是具体实现,后面把 JDBC 换成 MyBatis 不用动上层代码;第二,测试时可以 mock 一个 OrderDao 来模拟数据库异常,不用真的去操作数据库。
3.2 数据库表设计:用户表、车次表、订单表怎么建
数据库设计是这套系统的地基,表关系理不顺,后面全是补丁。通常拆成六张表:用户表、车次表、订单表、车次日期表、站点表,以及管理员表。
用户表存账号密码和联系方式;车次表存车次编号、始发站、终点站、出发时间、到达时间、票价、以及一个「基础余票量」;车次日期表是容易被漏掉但很关键的一张表——车次每天都有独立的余票数,因为今天是 5 张余票,明天可能是 20 张;订单表通过用户 ID 和车次日期 ID 关联到具体某一天的车次。
-- 车次日期表:解决「每天余票独立」的问题 CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, -- 关联车次表 depart_date DATE NOT NULL, -- 发车日期 remaining_tickets INT NOT NULL, -- 当日余票数 UNIQUE KEY uk_train_date (train_id, depart_date) ); -- 订单表:记录每个用户的预订记录 CREATE TABLE `order` ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, -- 订单编号 user_id INT NOT NULL, schedule_id INT NOT NULL, -- 关联到具体日期的车次 passenger_name VARCHAR(50) NOT NULL, id_card_no VARCHAR(18) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已完成 3已退票 create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) );订单状态用 TINYINT 存数字,不要存中文。第一是存储空间小,第二是状态扩展方便,第三是避免中文编码在数据库传输过程中出问题。页面上要展示状态时,用 JSTL 做映射即可。
3.3 表关系与数据流转设计
表之间的关系用外键还是只用逻辑关联,这是一个值得做决策的点。实际项目中我一般不用物理外键,而是在 DAO 层通过 JOIN 查询来关联数据。物理外键在删除车次时会带来额外的约束检查,而且这个系统里订单属于历史数据,不应该因为车次删除而受影响。
订单表和车次日期表之间的数据流转是这样的:下单时校验「车次日期表中的 remaining_tickets > 0」,确认支付时执行「UPDATE schedule SET remaining_tickets = remaining_tickets - 1 WHERE id = ? AND remaining_tickets > 0」。这条 SQL 是防止超卖的关键——直接在数据库层面做条件更新,而不是先查出来再判断。
-- 防超卖更新:条件更新保证原子性 UPDATE schedule SET remaining_tickets = remaining_tickets - 1 WHERE id = ? AND remaining_tickets > 0;如果更新影响的行数为 0,说明余票已经被抢完,这时订单就不能创建。这个设计比「先 SELECT 再 UPDATE」安全得多,第 5 章会展开说。
4. 核心模块实现:登录鉴权、车次查询与预订下单的完整落地
4.1 登录鉴权:Session 管理 + 过滤器拦截
登录模块看起来简单,但会话控制做不好,系统到处都是漏洞。登录接口接收到用户名和密码后,先做基础校验,再查数据库,密码匹配成功后把用户对象写入 Session。退出登录时调用 Session 的 invalidate 方法,而不是只跳转页面——这是最常见的坑,Session 没销毁,用户按返回键还能访问受保护页面。
受保护页面的统一拦截用 Filter 实现,比在每个 Servlet 里重复判断 Session 要干净得多。一个 LoginFilter 拦截所有 /user/* 和 /order/* 路径,Session 里没有用户对象就直接重定向到登录页。
// 登录过滤器:拦截未登录请求 public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, res); } }Filter 的 url-pattern 一定要写具体,比如/order/*,不要图省事写/*,否则登录页和静态资源也被拦截,会出现「登录页无限重定向」的经典事故。密码校验用加盐 MD5 是底线,数据库里不要出现明文密码。
4.2 车次查询:按出发地、目的地和日期筛选
车次查询是整个系统的入口功能,用户第一眼看到的就是这个页面。查询条件一般是出发地、目的地、出发日期三个字段。SQL 上用 JOIN 关联车次表和车次日期表,出发日期作为日期条件过滤。
有一个体验细节要注意:出发日期应该是必填项。如果不填日期,SQL 里就要写「当天或以后」的隐含条件,结果列表会把未来每一天的车次都拉出来,数据量大而且用户看着也累。前端用日期控件限制格式,后台仍要再做一次日期解析校验,防止传进来的字符串不是合法日期。
-- 车次查询 SQL SELECT t.train_no, t.depart_station, t.arrive_station, t.depart_time, t.arrive_time, t.ticket_price, s.remaining_tickets FROM train t JOIN schedule s ON t.id = s.train_id WHERE t.depart_station = ? AND t.arrive_station = ? AND s.depart_date = ? AND s.remaining_tickets > 0 ORDER BY t.depart_time;这个查询返回的是一个列表,每个车次带出它当天的余票数。用 PreparedStatement 而不是直接拼接 SQL,一方面防注入,另一方面数据库会对预编译语句做缓存,多次执行时性能更好。出发地和目的地不需要做模糊查询,精确匹配即可,但站点表里可以单独维护一份站点列表供前端下拉选择,减少用户输错的情况。
4.3 预订下单的事务处理:订单生成与余票扣减的一致性
下单模块是这套系统的核心,也是最容易出 bug 的地方。一个完整的下单动作包含两件事:插入一条订单记录、扣减车次日期表的余票数。这两件事必须在一个事务里完成——要么都成功,要么都失败。
用 Filter 拦截的模型下,事务控制不能放在 Servlet 里,因为一个请求可能会经过多个 DAO 调用。我一般把事务边界放在 Service 层,用 JDBC 的 setAutoCommit(false) 开启事务,操作完成后统一 commit,任何一步异常就 rollback。
// 下单事务:订单插入 + 余票扣减要么同时成功,要么同时失败 Connection conn = null; try { conn = DataSourceUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 OrderDao orderDao = new OrderDao(); ScheduleDao scheduleDao = new ScheduleDao(); // 1. 扣减余票,影响行数为 0 说明余票不足 int rows = scheduleDao.decreaseRemaining(conn, scheduleId); if (rows == 0) { throw new RuntimeException("余票不足,下单失败"); } // 2. 创建订单,状态为待支付 orderDao.insert(conn, order); conn.commit(); // 事务提交 } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (Exception ex) {} } log.error("下单事务失败", e); throw e; } finally { if (conn != null) { try { conn.close(); } catch (Exception e) {} } }注意 DAO 的方法签名都要接收 Connection 作为参数,这是保证它们加入同一个事务的关键。如果每个 DAO 内部自己开连接,事务就失效了——余票扣了但订单没插入,数据就不一致了。这是一个很容易踩的细节,很多初版代码都挂在这上面。
4.4 订单状态流转与后台车次管理
用户支付订单后,状态从未支付变为已支付,同时余票扣减的动作其实应该发生在支付成功这一刻,而不是生成订单时。这里有两种实现路线:一种是下单时就扣减余票,未支付订单取消时回补;另一种是支付时才扣减余票。前者的好处是能锁定座位,用户迟迟不支付会占着资源;后者更符合真实购票场景,但需要处理并发校验。实际毕设场景,建议下单时不扣余票、支付时扣余票,逻辑简单且不易产生「僵尸订单占票」的问题。
后台管理模块功能相对直接:车次列表展示、新增车次时同时初始化未来 N 天的车次日期记录、修改票价、停用车次。新增车次时初始化日期的逻辑很实用——一条 INSERT 语句用循环生成未来 7 天的 schedule 记录,这样用户在查询时不需要额外处理「今天没有排班」的情况。
5. 常见问题排查与避坑指南:五个最常翻车的点
5.1 中文乱码:JSP 页面、Servlet、数据库三层都要统一编码
现象:页面上显示的车站名称变成「??」,插入数据库的中文记录读出来是乱码。
原因:JSP 页面编码、Servlet 请求编码、数据库连接 URL 的编码、表字段的字符集,任何一层不一致都会导致乱码。最常见的是只设置了 JSP 的 pageEncoding,没有设置数据库连接 URL 的 characterEncoding。
解决:三层统一用 UTF-8。JSP 头部声明<%@ page contentType="text/html;charset=UTF-8" %>;Servlet 里对 request 和 response 分别调用setCharacterEncoding("UTF-8");JDBC 连接 URL 后面加上?useUnicode=true&characterEncoding=utf8。另外,MySQL 建库时指定DEFAULT CHARSET=utf8mb4,一劳永逸。排查时先确认表字符集,再确认连接 URL,不要一上来就改代码。
5.2 数据库连接泄漏:手动关闭 conn 是最容易被忽略的
现象:系统跑一段时间后,页面响应越来越慢,数据库连接池报连接数耗尽。
原因:很多初版代码只关闭了PreparedStatement和ResultSet,Connection 没有关闭。或者直接不关,以为 JVM 垃圾回收能处理。实际上 Connection 是重量级资源,必须显式归还给连接池。
解决:Connection 的关闭放在 finally 块里,或者是用 try-with-resources 语法。JDK 7 之后可以这样写:try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)),退出代码块自动关闭。从那以后我每次写 DAO 都强制走一遍这个写法,基本不会再碰到连接耗尽的问题。
5.3 并发下单超卖:先查再改是根因
现象:两个用户同时下单,余票明明只剩 1 张,两张订单都下成功了。
原因:代码写成了「先 SELECT 余票数,如果大于 0 就 INSERT 订单」。两个请求同时查出余票为 1,都通过校验,都执行插入操作,余票变成了负数。
解决:扣减余票用带条件 UPDATE,影响行数为 0 就抛异常回滚事务,正解是第 4.3 节写的写法。这是面试、答辩都会重点追问的点,能说清楚为什么不能「先查再改」,说明你真的理解并发场景下的数据一致性。
5.4 页面路径 404:跳转地址写错的三种情况
现象:点「预订」按钮后浏览器地址栏显示 404,或者明明跳转了但样式全丢了。
原因:第一,Servlet 注解@WebServlet("/order/add")和表单提交地址少了项目上下文路径;第二,JSP<a href="order/add">用的是相对路径,当前页面层级变了就失效;第三,登录过滤器拦截范围写太宽,登录请求本身被拦截导致死循环。
解决:所有页面跳转统一写「根路径 + 上下文路径」的完整形式,可以用${pageContext.request.contextPath}拼地址。比如${pageContext.request.contextPath}/order/add。绝对路径虽然啰嗦,但不会因为页面目录层级变化而翻车。
5.5 日期时间比较:字符串比较和时区踩坑
现象:查询“明天”的余票时,数据对不上,或者退票时间判断出现偏差。
原因:一种情况是页面传的日期字符串格式和数据库 DATE 字段格式不一致,导致隐式类型转换出问题;另一种情况是代码里用的new Date()和数据库时区不一致,前后差了 8 小时。
解决:统一用yyyy-MM-dd格式字符串传参,数据库 DATE 字段也按这个格式存储和比较。时间戳类型统一用DATETIME,插入时在 Java 侧生成。不要在 SQL 里写NOW()去依赖数据库服务器时间,每台机器的时区配置可能不一样,Java 统一生成时间戳可控性更高。
6. 验收与答辩:演示路径设计和评委追问的应答思路
6.1 按角色走通的验收清单
答辩演示最忌讳点开系统后不知道先展示什么。我通常建议按这条路径走:先演示注册新用户,然后登录,登录后查一次车次(刻意选一个热门路线),展示余票数量和票价信息,再走一遍完整预订流程,重点在支付完成后展示订单列表和余票减 1 的效果。最后切到管理员账号,展示车次列表、新增一个车次并初始化余票。
这条路径覆盖了所有核心功能,而且每一步之间都有因果关联——预订完成后,订单列表出现新记录,余票数量变化了,评委不需要问就能看到系统在正常工作。验收清单建议列成这样:注册与登录、车次查询、下单支付、订单状态流转、退票回补余票、后台车次 CRUD、未登录拦截、密码错误提示。
6.2 几个评委必问的问题怎么答
答辩环节最常被问到的问题基本围绕三个点:并发超卖如何解决、事务边界在哪里、Session 会话怎么管理。每个问题都要准备好「代码层面」的答案,不是只说概念。
比如超卖问题,回答套路是:下单事务中先执行带AND remaining_tickets > 0条件的 UPDATE,根据返回的行数判断是否成功,失败则回滚,这样数据库行锁天然保证只有一个请求能扣减成功。Session 问题要说出 Filter 的拦截路径设计,并且能解释为什么不用/*通配符。如果被问到「为什么不直接用 MyBatis」,可以大方承认经典 JDBC 方案是为了展示对 SQL 和事务控制原理的掌握,但系统 DAO 层的接口设计已经预留了替换空间——这比硬吹技术选型更可信。
6.3 答辩现场的节奏控制与收尾习惯
最后一件事是演示前的环境检查。数据库服务要先启动并确认连接正常,Tomcat 要单独配置一个端口避免冲突,演示时浏览器缓存要清掉。这些都是经历过现场翻车后的血泪经验——曾经见过有人在答辩现场因为数据库没启动,项目直接白屏,十分钟都在调环境,核心功能一个都没演示。
如果你用的是当前这套 JSP + Servlet 方案,最后一次完整走查时,我会建议你故意做一次「未登录访问订单页面」的演示——这是验证过滤器是否生效的最快方式。从那以后我每次交付这类 Web 项目,都会强制把「未登录访问、异常提交」这两条负向路径在验收前走一遍。这套方案把核心链路、并发安全、会话管理都覆盖到了,照着实现和准备,希望帮到你顺利过审。
本文还有配套的精品资源,点击获取