简介:本资源是一套完整的基于Web的车票预订信息系统毕业设计项目,面向计算机专业本科生及Java Web初学者,聚焦Web应用开发全流程实践,涵盖需求分析、系统设计、编码实现到数据库集成。项目采用Java语言开发,以Servlet处理业务逻辑、JSP构建动态前端界面、MySQL存储车次、用户及订单数据,并通过JDBC实现数据交互,适合作为课程设计或毕设参考,助力掌握B/S架构核心技能。压缩包共1337个文件,含57个JSP页面(负责用户交互与展示)、9个Java源文件(含Servlet与业务类)、108个CSS与14个SCSS/LESS样式文件(支撑界面布局)、272个JS脚本(实现前端交互逻辑),以及大量图片资源(PNG/GIF/JPG)和配套配置文件,整体大小55.82MB。已有1105人学习下载,资源附带完整可运行工程结构、数据库SQL脚本、MyEclipse环境配置说明,内容预览显示包含ASHX/ASP/ASPx等多类型服务端文件,体现跨技术兼容性设计,便于理解传统Web开发演进路径。
1. 为什么一个“老派”的JSP+Servlet车票预订系统,至今仍是毕设和课设的硬通货?
不是所有系统都要上Spring Cloud或微服务。某高校连续三年的软件工程课设清单里,“基于WEB的车票预订信息系统”稳居前三——不是因为它多前沿,而是它像一块“全功能试金石”:要画UML用例图和ER图,要写数据库建表语句和约束,要处理用户注册/登录/车次查询/余票计算/订单生成/支付模拟全流程,还要应对高并发下的库存超卖、重复提交、会话失效等真实问题。这套方案不玩概念,但每一步都踩在教学考核点上:MVC分层是否清晰?JSP页面有没有SQL注入风险?Servlet里事务怎么控制?Session和Cookie怎么协同?它适合那些需要交一份“结构完整、逻辑闭环、能跑能讲、答辩不翻车”的学生,也适合刚带课设的青年教师快速复用一套经得起推敲的参考骨架。别被“JSP”二字劝退——正是这种看似过时的技术栈,反而把Web开发最底层的请求-响应、状态管理、前后端协作暴露得清清楚楚。
2. 总体架构设计:B/S三层结构与五个核心模块的职责边界
这套系统采用经典的B/S三层架构:表现层(JSP页面)、业务逻辑层(Servlet+JavaBean)、数据访问层(JDBC直连MySQL)。没有引入框架,不是因为排斥技术演进,而是为了让学生看清每一层“谁该干什么”。比如,JSP只负责展示和简单跳转,绝不写业务逻辑;Servlet是真正的指挥官,接收请求、调用Service、决定跳转;而Service类封装了所有核心规则——查车次要校验发车时间是否合法,下单前必须原子性扣减余票并生成订单号。整个系统划分为五个不可拆分的模块,每个模块对应一个明确的数据库表和一组Servlet:
- 用户管理模块(user表):注册、登录、信息修改、密码找回(邮箱验证模拟)
- 车次管理模块(train表):车次编号、始发站/终点站、发车/到达时间、硬座/硬卧/二等座余票字段
- 查询模块(search.jsp + SearchServlet):支持按日期、出发地、目的地三条件组合查询,结果按发车时间排序
- 订单模块(order表 + OrderServlet):生成唯一订单号(yyyyMMddHHmmss+6位随机数),关联用户ID和车次ID,状态字段区分“已支付”“已取消”“待支付”
- 后台管理模块(admin目录):管理员登录后可增删改车次、查看所有订单、手动修改订单状态
提示:所有Servlet均继承自BaseServlet(自定义基类),统一处理编码(request.setCharacterEncoding("UTF-8"))、统一跳转逻辑(通过method参数反射调用具体方法),避免每个Servlet重复写doGet/doPost。
2.1 数据库设计:从ER图到建表语句的关键取舍
ER图中,用户(user)与订单(order)是一对多关系,车次(train)与订单(order)是多对一关系。这里有个关键设计点:余票字段不单独建表,而是直接放在train表中。原因很实际——车次信息变更频率极低,但余票查询是高频操作,每次查订单都要JOIN余票表会拖慢响应;而将余票作为train表字段,配合UPDATE语句的WHERE条件(如UPDATE train SET yu_piao = yu_piao - 1 WHERE train_id = ? AND yu_piao > 0),能用数据库行级锁天然防止超卖。建表语句如下(仅列关键字段):
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) UNIQUE NOT NULL, `password` VARCHAR(100) NOT NULL, -- 存BCrypt加密后的密文 `email` VARCHAR(100), `phone` VARCHAR(20) ); CREATE TABLE `train` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `train_no` VARCHAR(20) NOT NULL, -- G1001, D2023 `start_station` VARCHAR(50) NOT NULL, `end_station` VARCHAR(50) NOT NULL, `departure_time` DATETIME NOT NULL, `arrival_time` DATETIME NOT NULL, `yu_piao` INT DEFAULT 0, -- 余票数,初始为0,由后台录入 `price` DECIMAL(10,2) NOT NULL ); CREATE TABLE `order` ( `id` VARCHAR(32) PRIMARY KEY, -- 订单号,非自增 `user_id` INT NOT NULL, `train_id` INT NOT NULL, `status` ENUM('pending','paid','cancelled') DEFAULT 'pending', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME NULL, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`), FOREIGN KEY (`train_id`) REFERENCES `train`(`id`) );注意order.id用VARCHAR(32)而非INT——这是为后续扩展留余地(比如接入第三方支付需传入商户订单号),且避免自增ID暴露业务量。
2.2 核心流程实现:从查询到下单的四步原子化操作
以“用户下单”为例,这不是一个简单的INSERT操作,而是包含校验、锁库、生成、记录四步的原子链路。OrderServlet中的createOrder()方法逻辑如下:
public void createOrder(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取参数并校验(非空、格式、是否存在该车次) String trainIdStr = request.getParameter("train_id"); if (trainIdStr == null || trainIdStr.trim().isEmpty()) { request.setAttribute("msg", "车次ID不能为空"); request.getRequestDispatcher("/error.jsp").forward(request, response); return; } int trainId = Integer.parseInt(trainIdStr); // 2. 调用Service执行原子操作(关键!) OrderService orderService = new OrderServiceImpl(); String orderId = orderService.createOrder((Integer) request.getSession().getAttribute("userId"), trainId); if (orderId != null) { // 3. 下单成功,跳转到支付页(此处为模拟支付) request.setAttribute("orderId", orderId); request.getRequestDispatcher("/pay.jsp").forward(request, response); } else { // 4. 失败:余票不足或车次不存在 request.setAttribute("msg", "下单失败:余票不足或车次已取消"); request.getRequestDispatcher("/error.jsp").forward(request, response); } }其背后OrderServiceImpl.createOrder()的实现才是重点:
@Override public String createOrder(int userId, int trainId) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; String orderId = null; try { conn = JdbcUtils.getConnection(); // 自定义工具类获取连接 conn.setAutoCommit(false); // 开启事务 // 步骤1:检查车次是否存在且余票>0(SELECT FOR UPDATE加行锁) String checkSql = "SELECT yu_piao FROM train WHERE id = ? AND yu_piao > 0 FOR UPDATE"; ps = conn.prepareStatement(checkSql); ps.setInt(1, trainId); rs = ps.executeQuery(); if (!rs.next()) { return null; // 无余票,直接返回 } // 步骤2:扣减余票(UPDATE) String updateSql = "UPDATE train SET yu_piao = yu_piao - 1 WHERE id = ?"; ps = conn.prepareStatement(updateSql); ps.setInt(1, trainId); int affected = ps.executeUpdate(); if (affected == 0) { return null; // 扣减失败(可能被其他事务抢先) } // 步骤3:生成唯一订单号(防重放) orderId = generateOrderId(); // 步骤4:插入订单记录 String insertSql = "INSERT INTO `order` (id, user_id, train_id, status) VALUES (?, ?, ?, 'pending')"; ps = conn.prepareStatement(insertSql); ps.setString(1, orderId); ps.setInt(2, userId); ps.setInt(3, trainId); ps.executeUpdate(); conn.commit(); // 提交事务 return orderId; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return null; } finally { JdbcUtils.close(rs, ps, conn); } } private String generateOrderId() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String prefix = sdf.format(new Date()); String random = String.format("%06d", new Random().nextInt(1000000)); return prefix + random; }这段代码的价值在于:它把“查余票→扣余票→生订单→插记录”四步绑在同一个数据库事务里,并用SELECT ... FOR UPDATE在查余票时就对目标车次行加锁,确保同一车次不会被两个并发请求同时扣减。这是解决超卖问题最直接、最可控的方案,比应用层加synchronized或Redis分布式锁更适合教学场景——学生能一眼看懂锁在哪、事务在哪、回滚在哪。
3. 关键模块实现:登录认证、车次查询与订单状态机的落地细节
3.1 登录模块:从明文密码到BCrypt加密的渐进式安全加固
初版登录常犯的错误是:前端传明文密码,后端SQL直接拼接WHERE password = 'xxx'。这既不安全,也不符合现代实践。本方案采用BCrypt加密,但分两步走:先让学生理解“为什么不能存明文”,再教“怎么安全存储”。
第一步,在UserDao中,注册时对密码加密:
// 注册时调用 String encryptedPassword = BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); // 插入user表时存encryptedPassword第二步,登录验证时用BCrypt.checkpw:
// LoginServlet中 String inputPassword = request.getParameter("password"); String storedHash = userDao.findPasswordByUsername(username); // 从DB查出加密后的密文 if (BCrypt.checkpw(inputPassword, storedHash)) { // 登录成功,设置session request.getSession().setAttribute("userId", userId); request.getSession().setAttribute("username", username); response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); }注意:BCrypt.gensalt(12)中的12是log rounds,值越大越安全但越耗时。教学项目用10~12足够,避免学生调试时等太久。
3.2 车次查询模块:动态SQL拼接与分页的轻量实现
查询模块需支持“出发地、目的地、日期”三条件任意组合。若用固定SQL写死WHERE条件,会导致大量if-else分支。本方案采用StringBuilder动态拼接,清晰且易维护:
public List<Train> searchTrains(String start, String end, String date) { StringBuilder sql = new StringBuilder("SELECT * FROM train WHERE 1=1"); List<Object> params = new ArrayList<>(); int index = 1; if (start != null && !start.trim().isEmpty()) { sql.append(" AND start_station = ?"); params.add(start); } if (end != null && !end.trim().isEmpty()) { sql.append(" AND end_station = ?"); params.add(end); } if (date != null && !date.trim().isEmpty()) { sql.append(" AND DATE(departure_time) = ?"); params.add(date); // 前端传"2024-05-20" } sql.append(" ORDER BY departure_time ASC"); // 分页:LIMIT ?, ? (第一页从0开始) int offset = 0; // 实际项目应从参数读取 int pageSize = 10; sql.append(" LIMIT ?, ?"); params.add(offset); params.add(pageSize); // 执行查询(使用JDBC模板类) return queryList(sql.toString(), Train.class, params.toArray()); }这里的关键是:DATE(departure_time) = ?将DATETIME字段转为DATE比较,避免因时间部分不匹配导致查不到当天车次;分页用LIMIT ?, ?而非LIMIT 0,10,为后续支持页码参数留接口。
3.3 订单状态机:用ENUM字段驱动业务流转
订单状态不是随意更新的,必须遵循“待支付→已支付/已取消”的单向流转。本方案用MySQL的ENUM类型强制约束,并在Service层做状态校验:
// OrderService.updateStatus() public boolean updateStatus(String orderId, String newStatus) { // 先查当前状态 String currentStatus = orderDao.getStatusById(orderId); // 状态机规则:pending只能转paid或cancelled;paid不能再转 if ("pending".equals(currentStatus)) { if ("paid".equals(newStatus) || "cancelled".equals(newStatus)) { return orderDao.updateStatus(orderId, newStatus); } } return false; // 状态非法,拒绝更新 }后台管理页的“修改订单状态”按钮,前端JS根据当前状态动态显示可选操作(如当前是pending,则显示“标记为已支付”和“标记为已取消”两个按钮;若是paid,则按钮置灰)。这种前后端双重校验,比单纯靠数据库约束更健壮。
4. 避坑指南:五个让答辩老师当场皱眉的典型翻车点
4.1 现象:登录后刷新页面,Session丢失,又跳回登录页
原因:未正确配置web.xml中的session超时时间,或JSP页面未声明<%@ page session="true" %>,导致某些容器(如Tomcat 9+)默认不创建Session。更隐蔽的是,前端AJAX请求未携带JSESSIONID Cookie,或跨域时未设withCredentials: true。
解决:在web.xml中显式配置<session-config><session-timeout>30</session-timeout></session-config>;所有JSP顶部加<%@ page session="true" %>;检查浏览器开发者工具Application标签页,确认Cookie中有JSESSIONID且Domain匹配;若用AJAX,务必在请求头中带上凭证。
4.2 现象:同一车次被两个用户同时下单,余票变成-1
原因:未使用数据库行锁(SELECT ... FOR UPDATE),或事务未开启(conn.setAutoCommit(false)漏写),或锁粒度太大(对整张train表加锁而非单行)。
解决:严格按2.2节代码实现,确保SELECT ... FOR UPDATE与UPDATE在同一事务内;用EXPLAIN分析SQL执行计划,确认命中主键索引(否则FOR UPDATE会升级为表锁);测试时用JMeter开20线程并发抢同一车次,观察余票是否始终≥0。
4.3 现象:中文参数(如车站名)在URL中乱码,显示为“???”
原因:GET请求的URL参数编码与服务器解码不一致。Tomcat 8.5+默认用UTF-8解码,但若前端HTML未声明<meta charset="UTF-8">,或表单未设accept-charset="UTF-8",浏览器可能用GBK编码。
解决:统一强制UTF-8——HTML头部加<meta charset="UTF-8">;form标签加accept-charset="UTF-8";在Tomcat的server.xml中Connector节点添加URIEncoding="UTF-8";Servlet中对GET参数手动解码:new String(request.getParameter("start").getBytes("ISO-8859-1"), "UTF-8")(仅当确认是ISO编码时)。
4.4 现象:订单号生成重复,两个订单ID完全一样
原因:generateOrderId()方法中new Random().nextInt(1000000)在毫秒级并发下极易重复(Random种子相同)。更糟的是,未加synchronized或未用ThreadLocalRandom。
解决:改用ThreadLocalRandom.current().nextInt(1000000)替代new Random();或在订单号末尾追加System.nanoTime() % 1000(纳秒级精度);终极方案是用UUID前8位+时间戳,但教学项目用前者足矣。
4.5 现象:后台管理员删除车次后,已有订单仍显示该车次信息
原因:未设置外键级联(ON DELETE CASCADE),且订单表中train_id是外键,但查询订单详情时只JOIN train表,未考虑train记录已被删。
解决:建表时显式声明外键约束:FOREIGN KEY (train_id) REFERENCES train(id) ON DELETE SET NULL(需train_id字段允许NULL);或在查询订单详情的SQL中LEFT JOIN train,并判断train.id IS NULL来提示“车次已下线”。
5. 答辩验收实战:从演示脚本到答辩话术的全流程拆解
答辩不是代码朗诵,而是讲好一个“问题-方案-验证”的故事。我带过的某跨平台系统毕设,学生第一次答辩被问懵:“你这个余票扣减,如果网络断了,事务没提交,钱扣了但票没出,怎么办?”——其实他根本没设计支付回调,只是前端点了“支付成功”就跳转。从那以后,我要求所有学生答辩前必须走一遍三幕式演示脚本,并准备三份材料:一份精简版PPT(不超过10页)、一份数据库初始化SQL(含5条测试数据)、一份《异常场景测试清单》。
5.1 演示脚本:用5分钟讲清系统价值
第一幕(1分钟):痛点开场。打开浏览器,输入http://localhost:8080/ticket/login.jsp,输入测试账号test/123456登录。点击“查询车次”,输入“北京”“上海”“2024-05-20”,展示返回3条结果,强调“支持多条件组合查询,且结果按发车时间排序”。
第二幕(2分钟):核心流程。选中第一条G1001次,点击“立即预订”,跳转到确认页,显示余票“120张”。点击“提交订单”,弹出模拟支付页,点击“支付成功”,跳转到订单详情页,展示订单号、车次、状态“已支付”。此时立刻打开MySQL客户端,执行SELECT * FROM train WHERE id = 1,指出yu_piao已从120变为119——用数据库实时变化证明业务逻辑落地。
第三幕(2分钟):异常防御。新开一个浏览器窗口(无Session),直接访问http://localhost:8080/ticket/admin/,输入管理员账号admin/123456,进入后台,将G1001次余票手动改为“0”。再回到第一个窗口,尝试再次预订G1001,系统弹出“余票不足”,证明锁机制生效。最后展示《异常场景测试清单》中“并发抢票”“重复提交”“SQL注入测试”三项,说明已覆盖。
5.2 答辩话术:把技术选择转化为教学价值
老师常问:“为什么不用Spring Boot?”——别答“因为简单”,要说:“本设计聚焦Web开发本质。用原生Servlet,能清晰看到HttpServletRequest如何解析URL参数、HttpServletResponse如何写入响应头、Filter如何拦截请求;用JDBC直连,能亲手写PreparedStatement防注入、手写事务控制;这些是框架封装掉的‘黑匣子’,而课程设计的目标,恰恰是掀开黑匣子。”
问到“安全性怎么保证?”——不要只说“用了BCrypt”,要对比:“明文存储一旦泄露,所有用户密码裸奔;MD5加盐虽防彩虹表,但GPU暴力破解只需几小时;BCrypt内置慢哈希,12轮迭代让单次验证耗时100ms,暴力破解成本指数级上升——这是我们为用户账户安全加的第一道,也是最基础的一道锁。”
5.3 验收检查表:答辩前最后一遍自查
| 检查项 | 合格标准 | 不合格后果 |
|---|---|---|
| 数据库初始化 | 运行init.sql后,user表有test/admin两条用户,train表有至少3条车次(余票>0),order表为空 | 答辩演示时无法登录或查不到车次 |
| URL可访问性 | 所有JSP路径(login.jsp, index.jsp, admin/login.jsp)均能直接访问,无404 | 被质疑项目未部署完成 |
| 事务一致性 | 并发执行两次下单(用curl或Postman),余票减少2,订单表新增2条记录,且订单状态均为'pending' | 证明超卖漏洞未修复,核心功能不合格 |
| SQL注入防护 | 在查询框输入' OR '1'='1,页面返回“无结果”而非报错或列出所有车次 | 安全性一票否决 |
| Session隔离 | 两个浏览器分别登录test和admin,各自操作互不影响,admin删车次后test端下单立即报错 | 证明会话管理正确 |
从那以后我每次指导学生,都强制他们用这份检查表逐项打钩,哪怕多花半小时,也比答辩时被问住强。希望帮到你。
本文还有配套的精品资源,点击获取