简介:基于WEB的车票预订信息系统设计与实现项目源码,面向Java方向毕业生、课程设计学习者及希望提升Web开发能力的学生,覆盖Servlet、JSP、MySQL数据库等核心技能,适合用来完成车次查询、余票管理、在线预订、订单记录等模块的设计与编码。压缩包共1337个文件,约55.82MB,除JSP动态页面、Java源码、CSS样式、JavaScript脚本、图片素材和SQL数据库脚本外,还包含jar依赖包、Eclipse/MyEclipse工程配置、XML配置与少量操作演示视频,目录结构完整,可按功能分类快速定位。已有1105人学习下载,项目配有视频讲解,能帮助读者从环境搭建、项目导入到数据库连接和调试运行逐步走通流程。通过反复研读源码和实际调试,还能深入理解Java Web分层架构、Servlet请求处理机制、JSP页面交互以及JDBC数据访问方式,为毕业设计、课程报告或求职作品提供一套可直接改造复用的完整方案。1. 从抢票软件聊起的反直觉选型:为什么这套 JSP 车票预订系统值得拆一个每天都在刷票软件里抢票的人,可能不会想到课程设计级别的车票系统里,藏着事务、并发、Session 这些 Java Web 最核心的东西。这套基于 WEB 的车票预订信息系统,技术栈是 JSP + Servlet + MySQL,没有 Spring Boot,没有 MyBatis,连 Maven 都不需要。打开入口页面,注册、登录、查票、下单、出票,链路完整;顺着代码往下翻,JDBC 原生事务、行级锁、PreparedStatement 防注入这些基本功全都能找到。适合刚学完 Java Web 想梳理请求链路的人,也适合要做课程设计、想快速跑通再二次开发的人,后端熟手还能在它身上验证自己对 Servlet 生命周期和事务边界的理解。2. 技术架构拆解:JSP + Servlet + MySQL 三件套的工程落地2.1 技术选型:裸写 Java Web 为什么比框架更适合拆解启动这个车票预订系统之后,你会发现它完全没有框架痕迹。没有 Spring 容器,没有 MyBatis,依赖就是一个web.xml里声明的 Servlet 和一个放在WEB-INF/lib下的 mysql 驱动 jar。整个系统就是 JSP 做页面渲染、Servlet 做控制器、JDBC 直连 MySQL 的经典三层结构。我从首页index.jsp入手摸代码的时候,先看到${loginUser}这个 session 属性,顺着它走到了UserLoginServlet,再往下一层是UserDao。整个请求链路没有框架帮你跳转,所有路径都是开发者自己写的转发和重定向。对新手来说这是好消息:你能看到一次 HTTP 请求从浏览器到服务器、再到数据库、最后回页面的完整路径。对熟手来说,因为框架藏起来的细节全暴露在外面,反而适合验证自己对 Filter 顺序、事务边界、编码处理这些基础概念是不是真懂。有人会问,为什么不直接上 Spring Boot?答案要看使用场景。教学演示和课程项目这个层级,评判重点通常是业务闭环能不能讲清楚,而不是框架有多新。一个@RestController就完成的查询接口,在这个项目里要拆成 Servlet 接收参数、DAO 拼 SQL、JSP 用 JSTL 渲染三个步骤。正是这种裸写,让每个环节都没地方躲。2.2 工程目录结构:src、WebRoot、sql 三块各自承担什么把 rar 解开后,项目是三个核心目录的结构:src放 Java 源码,WebRoot是 Web 应用根目录,sql放初始化脚本。最常见的包结构长这样:TicketSystem/ ├── src/com/ticket/ │ ├── entity/ # 实体类 User.java / Train.java / Order.java │ ├── dao/ # 数据访问层 UserDao / TrainDao / OrderDao │ ├── service/ # 业务层,封装订票、退票等事务逻辑 │ ├── servlet/ # 控制器层,接收请求并跳转页面 │ ├── filter/ # LoginFilter 登录拦截、EncodingFilter 编码过滤 │ ├── util/ # DBUtil 连接管理、MD5Util 密码加密 │ └── listener/ # 启动时加载配置的监听器 ├── WebRoot/ │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置:Servlet 映射、Filter、欢迎页 │ │ └── lib/ # mysql-connector-java.jar / jstl.jar │ ├── css/ js/ fonts/ │ ├── index.jsp # 首页和入口登录 │ ├── search.jsp # 车次查询 │ ├── order_list.jsp # 我的订单 │ └── admin/ # 后台管理页面 └── sql/ ├── ticket_system.sql # 建库建表 └── init_data.sql # 示例车次数据web.xml值得多看两眼。虽然是 Servlet 3.1 规范了,项目仍然用 XML 声明了LoginFilter的映射和各个 Servlet 的路径。登录拦截是这么配的:<filter> <filter-name>LoginFilter</filter-name> <filter-class>com.ticket.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>URL 匹配/*意味着所有请求都会过过滤器,在 Java 代码里判断 session 是否存在,不存在就重定向回login.jsp。这种写法比注解直观,特别是你要给别人讲“请求进来先过谁、再进哪个 Servlet”的时候,直接在 XML 里按顺序看就够了。2.3 数据库设计:三张核心表如何支撑整个订票闭环整个系统只有三张表:t_user管用户,t_train管车次和余票,t_order管订单。初始化脚本里最有价值的字段是t_train.remaining和t_order.status,前者是后续并发控制的关键,后者是订单状态机的锚点。表名核心字段作用t_userid, username, password, id_card, phone用户注册与登录,密码存的是 MD5 值t_trainid, train_no, depart_station, arrive_station, depart_time, arrive_time, price, remaining, seat_total车次基本信息,remaining 表示实时余票t_orderid, order_no, user_id, train_id, ticket_count, total_price, status, create_time订单表,status 用 0 待支付、1 已支付、2 已取消订单设计上,order_no是生成的唯一流水号,user_id和train_id外键关联另两张表。注意t_order.ticket_count和t_train.remaining之间没有数据库级约束,余票到底够不够完全靠程序判断。这个设计在并发量上来之后会翻车,第 4 章会讲具体解决思路。初始化脚本里的示例数据很重要。跑起来之后至少要有 4 到 5 条不同车次,最好是同一条线路不同时段的排班,方便测查询、订票和后续取消逻辑。执行完init_data.sql就能用预置的普通用户登录,后台能看到这些数据。3. 核心模块拆解:从登录到出票的业务链路逐段走读3.1 登录模块:Session 怎么把一次登录状态串起来登录是第一个要读的 Servlet。login.jsp把表单 POST 到UserLoginServlet,doPost做了三件事:取参数、查库、决定去留。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = MD5Util.md5(request.getParameter("password")); UserDao userDao = new UserDao(); User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); response.sendRedirect("index.jsp"); } else { request.setAttribute("errorMsg", "账号或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } }这段代码有三个关键点。第一,密码不是明文比对,入库前用 MD5 算过一次,数据库里存的是 32 位哈希值。第二,登录成功把user对象塞进session.setAttribute,后续页面通过${sessionScope.loginUser.username}就能拿到当前用户。第三,失败时用forward转发回登录页,errorMsg参数通过${requestScope.errorMsg}在 JSP 里渲染。sendRedirect和forward的区别在这里体现得很明显:成功用重定向,地址栏变成index.jsp,URL 不暴露会话信息;失败用转发,request 里的属性还能继续用。很多新手把这两个混用,结果报错信息永远传不过去。3.2 车次查询模块:PreparedStatement 拼接多条件查询查询页search.jsp提供出发站、到达站、发车日期三个输入框,提交后由TrainSearchServlet接收,再丢给TrainDao.search。核心 DAO 方法是这份代码里最值得抄的一段:public List<Train> search(String from, String to, String date) { String sql = "SELECT * FROM t_train WHERE 1 = 1"; List<Object> params = new ArrayList<>(); if (from != null && !"".equals(from.trim())) { sql += " AND depart_station LIKE ?"; params.add("%" + from.trim() + "%"); } if (to != null && !"".equals(to.trim())) { sql += " AND arrive_station LIKE ?"; params.add("%" + to.trim() + "%"); } if (date != null && !"".equals(date.trim())) { sql += " AND depart_time >= ? AND depart_time < DATE_ADD(?, INTERVAL 1 DAY)"; params.add(date + " 00:00:00"); params.add(date); } // 用 PreparedStatement 执行,占位符 ? 逐个 setObject }WHERE 1 = 1看起来像玄学,其实是给动态拼接省判断。每个条件参数都塞进params列表,最后统一preparedStatement.setObject(i, params.get(i - 1))。这样完全避开字符串拼接 SQL,防住注入的关键一步。日期查询没用=匹配,而是取一天的开始到第二天的开始,中间用DATE_ADD做闭区间。因为depart_time是DATETIME,只比日期会丢掉时间精度。查询结果List<Train>放进 request 的trainList属性,转发到search_result.jsp由<c:forEach>渲染成表格。3.3 预订车票模块:事务边界与行锁保证不超卖订票是整个项目最核心也最容易出问题的模块。业务上要同时做两件事:扣减t_train.remaining,插入一条t_order。这两步必须在一个数据库事务里,否则会出现“订单生成了但余票没扣”或“票扣了订单没生成”。public void book(Connection conn, Order order, int count) throws Exception { conn.setAutoCommit(false); try { // 行级锁定查询当前余票 Train train = trainDao.findByIdForUpdate(conn, order.getTrainId()); if (train.getRemaining() < count) { throw new RuntimeException("余票不足"); } trainDao.decreaseRemaining(conn, order.getTrainId(), count); orderDao.insert(conn, order); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }findByIdForUpdate内部执行的是SELECT * FROM t_train WHERE id = ? FOR UPDATE。这是 MySQL 的悲观锁:SQL 执行后 InnoDB 锁住对应行,其他事务的更新必须等当前事务提交或回滚。两个用户同时抢同一车次时,第二个请求会阻塞在FOR UPDATE上,第一个提交后再读到最新的remaining。这段逻辑放在 service 层而不是 Servlet 层,是因为事务边界必须覆盖 DAO 的多个操作。直接在BookTicketServlet里写,后面再加“锁定订单”“账务流水”这些步骤,事务会越写越乱。我把这当作这个项目给的最大设计经验:事务从 service 方法开始,到 service 方法结束,不放进 Web 层。count参数是购票张数,decreaseRemaining执行UPDATE t_train SET remaining = remaining - ? WHERE id = ?。有了前面的FOR UPDATE判断一定准确。如果哪天项目改成高并发存储过程,就要换乐观锁方案,用WHERE remaining >= ?作为更新条件,靠受影响行数判断是否失败。4. 部署运行与常见问题避坑:Tomcat 环境下让项目跑起来4.1 本地部署五步走:从建库到浏览器出首页跑这个项目不需要复杂环境,JDK、Tomcat、MySQL 三件套就够。部署顺序我建议严格按下面来。第一步建库:打开 MySQL,执行sql/ticket_system.sql里的建库建表语句,再执行init_data.sql灌入示例车次。注意顺序不能反过来,先有表结构才能写数据。第二步改连接配置:找到src/util/DBUtil.java(或db.properties),把连接串里的账号密码改成你本机的。常见写法是jdbc:mysql://localhost:3306/ticket_system?useSSL=false&characterEncoding=utf8,useSSL=false能省掉 MySQL 5.7 在本地环境不必要的 SSL 握手警告。第三步编译打包:如果手编,在src目录用javac -cp把所有 java 文件编译到WEB-INF/classes;更省事的做法是在 IDEA 或 Eclipse 里打成 war 包,扔到 Tomcat 的webapps目录下。第四步启动 Tomcat:到bin目录执行startup.bat或startup.sh,看到日志出现Server startup就说明启动成功。第五步访问:浏览器打开http://localhost:8080/项目名/,先注册一个普通用户,再登录完成一次完整的查票订票操作。前端页面能走通,说明配置基本没问题。4.2 高频问题排查记录:五条具体踩坑实录问题一:首页永远出不来,直接跳到 404现象:访问项目路径直接给 Tomcat 的默认 404 页面。原因大多出在 war 包没放对位置,或者web.xml里没有配置<welcome-file-list>。解决:先确认 war 解压出来的目录名和访问路径一致,再检查WEB-INF/web.xml里 welcome-file 是不是index.jsp。两者都对了页面就能出来。问题二:页面中文乱码,数据库里存成问号现象:登录后用户名在页面上显示一串??。原因有两层:POST 请求的正文编码没有指定,Tomcat 默认用 ISO-8859-1 解码;连接串没带characterEncoding=utf8,JDBC 按 latin1 写入。解决:写一个EncodingFilter,对所有 request 和 response 设成 UTF-8;同时在连接 URL 末尾补characterEncoding=utf8。两处都改完再重启 Tomcat。问题三:ClassNotFoundException: com.mysql.jdbc.Driver现象:一跑查询就报找不到驱动类。原因:驱动 jar 没进WEB-INF/lib。大多数 IDE 调试时依赖由 IDE 自己挂到 classpath,部署到 Tomcat 后它只认WEB-INF/lib下的包,手打 war 包里缺了这层保障。解决:把mysql-connector-java-x.x.x.jar直接拷贝到WEB-INF/lib目录,重新打包发布。问题四:余票变成负数现象:同一条线路两个浏览器同时下单,最后余票字段出现负数。原因:DAO 里“先查再改”的读写不是原子的,两个线程同时读到remaining=1,各自扣 1。解决:按第 3 章的方案在查询后加FOR UPDATE;或者把扣减 SQL 改成UPDATE ... SET remaining = remaining - ? WHERE id = ? AND remaining >= ?,判断受影响行数,为 0 就报余票不足。两种方案我都在本地验证过,后者并发性能更好。问题五:JSP 页面里的 ${value} 原样输出现象:${sessionScope.loginUser.username}在页面上直接显示成字符串。原因:web.xml头部声明的是 Servlet 2.3 旧规范,JSP 2.0 里 EL 默认关闭。解决:把 web.xml 版本声明改成 Servlet 3.1 的 xsdl,同时引入 jstl.jar 和 standard.jar 到WEB-INF/lib,在 JSP 顶部加<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。5. 页面交互拆解:JSP 渲染与异步刷新的两种写法5.1 查询结果页:用 JSTL 渲染车次表格search_result.jsp是典型的服务端渲染。TrainSearchServlet把trainList放进 request,JSP 用<c:forEach>循环输出每一行车次,每行带一个“预订”按钮,按钮值传车次 ID。<form action="trainSearch" method="post"> <input type="text" name="from" placeholder="出发站"> <input type="text" name="to" placeholder="到达站"> <input type="date" name="date"> <button type="submit">查询</button> </form> <table> <c:forEach var="train" items="${trainList}"> <tr> <td>${train.trainNo}</td> <td>${train.departStation} → ${train.arriveStation}</td> <td>${train.departTime} - ${train.arriveTime}</td> <td>¥${train.price}</td> <td>${train.remaining} 张</td> <td><a href="book.jsp?trainId=${train.id}">预订</a></td> </tr> </c:forEach> </table>这段 JSP 的细节在于把列表展示和跳转入口放在同一个表格里。${train.remaining}显示当前余票,但前端没有做售罄拦截,余票为 0 时“预订”按钮依然可以点,超卖检查完全靠后端第 3 章的事务逻辑扛。实战中我会先加一层:余票为 0 时把“预订”换成“已售罄”,用<c:if test="${train.remaining == 0}">条件渲染就能实现。表格里“出发站 → 到达站”用的是 HTML 字符串拼接。这里没转义风险,因为值来自初始化脚本的固定数据。如果以后改成用户自定义车次名,就要小心 XSS,输出前需要转义。5.2 局部异步刷新:少整页跳转,多 Ajax 联动首页最近的热门线路和订单状态这块是用 Ajax 做的局部刷新,不经过TrainSearchServlet,单独暴露了一个HotRoutesServlet,返回 JSON 数组,前端用 jQuery 的$.get拉取后拼到 DOM 上。$.get('hotRoutes', { city: 'Shanghai' }, function (data) { var list = $('#hot-routes'); data.forEach(function (item) { list.append('<li>' + item.depart + ' 到 ' + item.arrive + '</li>'); }); }, 'json');Servlet 返回 JSON 的常见做法是手动拼字符串,配合response.setContentType("application/json;charset=UTF-8")。这里有个容易翻车的点:手拼 JSON 必须自己处理引号转义和 null 值,一旦字段里带双引号就会解析失败。本项目是演示用所以没引 JSON 库,你要是想再加接口,建议直接上 fastjson 或 Gson,一行JSON.toJSONString(list)就能序列化整个车次列表。5.3 订单列表的状态回显与按钮联动订单页order_list.jsp读取当前用户的全部订单,每一行显示车次、票数、金额、下单时间和状态。状态字段status是数字,页面要做一次中文映射:0 显示“待支付”,1 显示“已支付”,2 显示“已取消”。常见做法是在循环里套<c:choose>。<c:choose> <c:when test="${order.status == 0}">待支付</c:when> <c:when test="${order.status == 1}">已支付</c:when> <c:otherwise>已取消</c:otherwise> </c:choose>不同状态还会触发不同的操作按钮:已支付订单显示“取消订票”,待支付订单显示“去支付”,已取消订单按钮置灰。这套联动逻辑全靠status一个字段判断,所以后端在更新状态时一定要保证事务一致性,否则页面会渲染出“已取消订单还能取消”这种自相矛盾的按钮。6. 二次开发实战:给车票系统加上“取消订票”并回补余票6.1 需求与接口:一个最小的状态变更闭环最常见的扩展需求是取消订票、改签、退票,本质上都是对t_order.status做流转,同时回补或调整t_train.remaining。这里以取消订票为例,讲一个最小闭环的改法。先在order_list.jsp的已支付订单后面加一个“取消”按钮,点击后 POST 到新增的CancelOrderServlet,携带orderNo。Servlet 内部做三件事:按订单号查订单、把status从 1 改成 2、把余票加回去。这三个动作必须在一个事务里,否则会出现“余票加了订单还是已支付”的脏数据。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String orderNo = request.getParameter("orderNo"); Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); Order order = orderDao.findByOrderNo(conn, orderNo); if (order == null || "2".equals(order.getStatus())) { throw new RuntimeException("订单不存在或已取消"); } orderDao.updateStatus(conn, orderNo, "2"); trainDao.increaseRemaining(conn, order.getTrainId(), order.getTicketCount()); conn.commit(); response.sendRedirect("order_list.jsp?msg=cancel_success"); } catch (Exception e) { conn.rollback(); request.setAttribute("errorMsg", e.getMessage()); request.getRequestDispatcher("order_list.jsp").forward(request, response); } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }这段代码里容易漏掉的是取消前的状态校验。订单已经取消过,再走一次取消逻辑会把余票重复加回去。这个幂等检查在真实项目里叫状态机守卫,很多接口的翻车事故都出在这里。6.2 验证方法与回归要点改完先别急着点按钮。我的验证流程分三步:第一步,用两个账号各下一单,确认订单数和余票数落在初始化脚本的基线值上;第二步,对同一订单连续点两次取消,第二次必须报“订单不存在或已取消”,同时数据库里remaining只增加了一次;第三步,开两个浏览器同时取消同一笔订单,看事务锁能不能拦住后进入的那个请求。三步都过了,再补页面提示:取消成功后把按钮置灰,用<c:if test="${order.status eq 1}">控制渲染,让用户知道状态已经不可逆。从那以后,我每次给带库存属性的业务加接口,都强制自己走一遍“先查状态机、再改数、再回归并发”的流程。这种裸写的 JSP 项目没有框架兜底,每一步都得自己画清楚,反而逼着人把事务边界和幂等性想明白。希望帮到你。本文还有配套的精品资源,点击获取
简介:基于WEB的车票预订信息系统设计与实现项目源码,面向Java方向毕业生、课程设计学习者及希望提升Web开发能力的学生,覆盖Servlet、JSP、MySQL数据库等核心技能,适合用来完成车次查询、余票管理、在线预订、订单记录等模块的设计与编码。压缩包共1337个文件,约55.82MB,除JSP动态页面、Java源码、CSS样式、JavaScript脚本、图片素材和SQL数据库脚本外,还包含jar依赖包、Eclipse/MyEclipse工程配置、XML配置与少量操作演示视频,目录结构完整,可按功能分类快速定位。已有1105人学习下载,项目配有视频讲解,能帮助读者从环境搭建、项目导入到数据库连接和调试运行逐步走通流程。通过反复研读源码和实际调试,还能深入理解Java Web分层架构、Servlet请求处理机制、JSP页面交互以及JDBC数据访问方式,为毕业设计、课程报告或求职作品提供一套可直接改造复用的完整方案。1. 从抢票软件聊起的反直觉选型:为什么这套 JSP 车票预订系统值得拆一个每天都在刷票软件里抢票的人,可能不会想到课程设计级别的车票系统里,藏着事务、并发、Session 这些 Java Web 最核心的东西。这套基于 WEB 的车票预订信息系统,技术栈是 JSP + Servlet + MySQL,没有 Spring Boot,没有 MyBatis,连 Maven 都不需要。打开入口页面,注册、登录、查票、下单、出票,链路完整;顺着代码往下翻,JDBC 原生事务、行级锁、PreparedStatement 防注入这些基本功全都能找到。适合刚学完 Java Web 想梳理请求链路的人,也适合要做课程设计、想快速跑通再二次开发的人,后端熟手还能在它身上验证自己对 Servlet 生命周期和事务边界的理解。2. 技术架构拆解:JSP + Servlet + MySQL 三件套的工程落地2.1 技术选型:裸写 Java Web 为什么比框架更适合拆解启动这个车票预订系统之后,你会发现它完全没有框架痕迹。没有 Spring 容器,没有 MyBatis,依赖就是一个web.xml里声明的 Servlet 和一个放在WEB-INF/lib下的 mysql 驱动 jar。整个系统就是 JSP 做页面渲染、Servlet 做控制器、JDBC 直连 MySQL 的经典三层结构。我从首页index.jsp入手摸代码的时候,先看到${loginUser}这个 session 属性,顺着它走到了UserLoginServlet,再往下一层是UserDao。整个请求链路没有框架帮你跳转,所有路径都是开发者自己写的转发和重定向。对新手来说这是好消息:你能看到一次 HTTP 请求从浏览器到服务器、再到数据库、最后回页面的完整路径。对熟手来说,因为框架藏起来的细节全暴露在外面,反而适合验证自己对 Filter 顺序、事务边界、编码处理这些基础概念是不是真懂。有人会问,为什么不直接上 Spring Boot?答案要看使用场景。教学演示和课程项目这个层级,评判重点通常是业务闭环能不能讲清楚,而不是框架有多新。一个@RestController就完成的查询接口,在这个项目里要拆成 Servlet 接收参数、DAO 拼 SQL、JSP 用 JSTL 渲染三个步骤。正是这种裸写,让每个环节都没地方躲。2.2 工程目录结构:src、WebRoot、sql 三块各自承担什么把 rar 解开后,项目是三个核心目录的结构:src放 Java 源码,WebRoot是 Web 应用根目录,sql放初始化脚本。最常见的包结构长这样:TicketSystem/ ├── src/com/ticket/ │ ├── entity/ # 实体类 User.java / Train.java / Order.java │ ├── dao/ # 数据访问层 UserDao / TrainDao / OrderDao │ ├── service/ # 业务层,封装订票、退票等事务逻辑 │ ├── servlet/ # 控制器层,接收请求并跳转页面 │ ├── filter/ # LoginFilter 登录拦截、EncodingFilter 编码过滤 │ ├── util/ # DBUtil 连接管理、MD5Util 密码加密 │ └── listener/ # 启动时加载配置的监听器 ├── WebRoot/ │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置:Servlet 映射、Filter、欢迎页 │ │ └── lib/ # mysql-connector-java.jar / jstl.jar │ ├── css/ js/ fonts/ │ ├── index.jsp # 首页和入口登录 │ ├── search.jsp # 车次查询 │ ├── order_list.jsp # 我的订单 │ └── admin/ # 后台管理页面 └── sql/ ├── ticket_system.sql # 建库建表 └── init_data.sql # 示例车次数据web.xml值得多看两眼。虽然是 Servlet 3.1 规范了,项目仍然用 XML 声明了LoginFilter的映射和各个 Servlet 的路径。登录拦截是这么配的:<filter> <filter-name>LoginFilter</filter-name> <filter-class>com.ticket.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>URL 匹配/*意味着所有请求都会过过滤器,在 Java 代码里判断 session 是否存在,不存在就重定向回login.jsp。这种写法比注解直观,特别是你要给别人讲“请求进来先过谁、再进哪个 Servlet”的时候,直接在 XML 里按顺序看就够了。2.3 数据库设计:三张核心表如何支撑整个订票闭环整个系统只有三张表:t_user管用户,t_train管车次和余票,t_order管订单。初始化脚本里最有价值的字段是t_train.remaining和t_order.status,前者是后续并发控制的关键,后者是订单状态机的锚点。表名核心字段作用t_userid, username, password, id_card, phone用户注册与登录,密码存的是 MD5 值t_trainid, train_no, depart_station, arrive_station, depart_time, arrive_time, price, remaining, seat_total车次基本信息,remaining 表示实时余票t_orderid, order_no, user_id, train_id, ticket_count, total_price, status, create_time订单表,status 用 0 待支付、1 已支付、2 已取消订单设计上,order_no是生成的唯一流水号,user_id和train_id外键关联另两张表。注意t_order.ticket_count和t_train.remaining之间没有数据库级约束,余票到底够不够完全靠程序判断。这个设计在并发量上来之后会翻车,第 4 章会讲具体解决思路。初始化脚本里的示例数据很重要。跑起来之后至少要有 4 到 5 条不同车次,最好是同一条线路不同时段的排班,方便测查询、订票和后续取消逻辑。执行完init_data.sql就能用预置的普通用户登录,后台能看到这些数据。3. 核心模块拆解:从登录到出票的业务链路逐段走读3.1 登录模块:Session 怎么把一次登录状态串起来登录是第一个要读的 Servlet。login.jsp把表单 POST 到UserLoginServlet,doPost做了三件事:取参数、查库、决定去留。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = MD5Util.md5(request.getParameter("password")); UserDao userDao = new UserDao(); User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); response.sendRedirect("index.jsp"); } else { request.setAttribute("errorMsg", "账号或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } }这段代码有三个关键点。第一,密码不是明文比对,入库前用 MD5 算过一次,数据库里存的是 32 位哈希值。第二,登录成功把user对象塞进session.setAttribute,后续页面通过${sessionScope.loginUser.username}就能拿到当前用户。第三,失败时用forward转发回登录页,errorMsg参数通过${requestScope.errorMsg}在 JSP 里渲染。sendRedirect和forward的区别在这里体现得很明显:成功用重定向,地址栏变成index.jsp,URL 不暴露会话信息;失败用转发,request 里的属性还能继续用。很多新手把这两个混用,结果报错信息永远传不过去。3.2 车次查询模块:PreparedStatement 拼接多条件查询查询页search.jsp提供出发站、到达站、发车日期三个输入框,提交后由TrainSearchServlet接收,再丢给TrainDao.search。核心 DAO 方法是这份代码里最值得抄的一段:public List<Train> search(String from, String to, String date) { String sql = "SELECT * FROM t_train WHERE 1 = 1"; List<Object> params = new ArrayList<>(); if (from != null && !"".equals(from.trim())) { sql += " AND depart_station LIKE ?"; params.add("%" + from.trim() + "%"); } if (to != null && !"".equals(to.trim())) { sql += " AND arrive_station LIKE ?"; params.add("%" + to.trim() + "%"); } if (date != null && !"".equals(date.trim())) { sql += " AND depart_time >= ? AND depart_time < DATE_ADD(?, INTERVAL 1 DAY)"; params.add(date + " 00:00:00"); params.add(date); } // 用 PreparedStatement 执行,占位符 ? 逐个 setObject }WHERE 1 = 1看起来像玄学,其实是给动态拼接省判断。每个条件参数都塞进params列表,最后统一preparedStatement.setObject(i, params.get(i - 1))。这样完全避开字符串拼接 SQL,防住注入的关键一步。日期查询没用=匹配,而是取一天的开始到第二天的开始,中间用DATE_ADD做闭区间。因为depart_time是DATETIME,只比日期会丢掉时间精度。查询结果List<Train>放进 request 的trainList属性,转发到search_result.jsp由<c:forEach>渲染成表格。3.3 预订车票模块:事务边界与行锁保证不超卖订票是整个项目最核心也最容易出问题的模块。业务上要同时做两件事:扣减t_train.remaining,插入一条t_order。这两步必须在一个数据库事务里,否则会出现“订单生成了但余票没扣”或“票扣了订单没生成”。public void book(Connection conn, Order order, int count) throws Exception { conn.setAutoCommit(false); try { // 行级锁定查询当前余票 Train train = trainDao.findByIdForUpdate(conn, order.getTrainId()); if (train.getRemaining() < count) { throw new RuntimeException("余票不足"); } trainDao.decreaseRemaining(conn, order.getTrainId(), count); orderDao.insert(conn, order); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }findByIdForUpdate内部执行的是SELECT * FROM t_train WHERE id = ? FOR UPDATE。这是 MySQL 的悲观锁:SQL 执行后 InnoDB 锁住对应行,其他事务的更新必须等当前事务提交或回滚。两个用户同时抢同一车次时,第二个请求会阻塞在FOR UPDATE上,第一个提交后再读到最新的remaining。这段逻辑放在 service 层而不是 Servlet 层,是因为事务边界必须覆盖 DAO 的多个操作。直接在BookTicketServlet里写,后面再加“锁定订单”“账务流水”这些步骤,事务会越写越乱。我把这当作这个项目给的最大设计经验:事务从 service 方法开始,到 service 方法结束,不放进 Web 层。count参数是购票张数,decreaseRemaining执行UPDATE t_train SET remaining = remaining - ? WHERE id = ?。有了前面的FOR UPDATE判断一定准确。如果哪天项目改成高并发存储过程,就要换乐观锁方案,用WHERE remaining >= ?作为更新条件,靠受影响行数判断是否失败。4. 部署运行与常见问题避坑:Tomcat 环境下让项目跑起来4.1 本地部署五步走:从建库到浏览器出首页跑这个项目不需要复杂环境,JDK、Tomcat、MySQL 三件套就够。部署顺序我建议严格按下面来。第一步建库:打开 MySQL,执行sql/ticket_system.sql里的建库建表语句,再执行init_data.sql灌入示例车次。注意顺序不能反过来,先有表结构才能写数据。第二步改连接配置:找到src/util/DBUtil.java(或db.properties),把连接串里的账号密码改成你本机的。常见写法是jdbc:mysql://localhost:3306/ticket_system?useSSL=false&characterEncoding=utf8,useSSL=false能省掉 MySQL 5.7 在本地环境不必要的 SSL 握手警告。第三步编译打包:如果手编,在src目录用javac -cp把所有 java 文件编译到WEB-INF/classes;更省事的做法是在 IDEA 或 Eclipse 里打成 war 包,扔到 Tomcat 的webapps目录下。第四步启动 Tomcat:到bin目录执行startup.bat或startup.sh,看到日志出现Server startup就说明启动成功。第五步访问:浏览器打开http://localhost:8080/项目名/,先注册一个普通用户,再登录完成一次完整的查票订票操作。前端页面能走通,说明配置基本没问题。4.2 高频问题排查记录:五条具体踩坑实录问题一:首页永远出不来,直接跳到 404现象:访问项目路径直接给 Tomcat 的默认 404 页面。原因大多出在 war 包没放对位置,或者web.xml里没有配置<welcome-file-list>。解决:先确认 war 解压出来的目录名和访问路径一致,再检查WEB-INF/web.xml里 welcome-file 是不是index.jsp。两者都对了页面就能出来。问题二:页面中文乱码,数据库里存成问号现象:登录后用户名在页面上显示一串??。原因有两层:POST 请求的正文编码没有指定,Tomcat 默认用 ISO-8859-1 解码;连接串没带characterEncoding=utf8,JDBC 按 latin1 写入。解决:写一个EncodingFilter,对所有 request 和 response 设成 UTF-8;同时在连接 URL 末尾补characterEncoding=utf8。两处都改完再重启 Tomcat。问题三:ClassNotFoundException: com.mysql.jdbc.Driver现象:一跑查询就报找不到驱动类。原因:驱动 jar 没进WEB-INF/lib。大多数 IDE 调试时依赖由 IDE 自己挂到 classpath,部署到 Tomcat 后它只认WEB-INF/lib下的包,手打 war 包里缺了这层保障。解决:把mysql-connector-java-x.x.x.jar直接拷贝到WEB-INF/lib目录,重新打包发布。问题四:余票变成负数现象:同一条线路两个浏览器同时下单,最后余票字段出现负数。原因:DAO 里“先查再改”的读写不是原子的,两个线程同时读到remaining=1,各自扣 1。解决:按第 3 章的方案在查询后加FOR UPDATE;或者把扣减 SQL 改成UPDATE ... SET remaining = remaining - ? WHERE id = ? AND remaining >= ?,判断受影响行数,为 0 就报余票不足。两种方案我都在本地验证过,后者并发性能更好。问题五:JSP 页面里的 ${value} 原样输出现象:${sessionScope.loginUser.username}在页面上直接显示成字符串。原因:web.xml头部声明的是 Servlet 2.3 旧规范,JSP 2.0 里 EL 默认关闭。解决:把 web.xml 版本声明改成 Servlet 3.1 的 xsdl,同时引入 jstl.jar 和 standard.jar 到WEB-INF/lib,在 JSP 顶部加<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。5. 页面交互拆解:JSP 渲染与异步刷新的两种写法5.1 查询结果页:用 JSTL 渲染车次表格search_result.jsp是典型的服务端渲染。TrainSearchServlet把trainList放进 request,JSP 用<c:forEach>循环输出每一行车次,每行带一个“预订”按钮,按钮值传车次 ID。<form action="trainSearch" method="post"> <input type="text" name="from" placeholder="出发站"> <input type="text" name="to" placeholder="到达站"> <input type="date" name="date"> <button type="submit">查询</button> </form> <table> <c:forEach var="train" items="${trainList}"> <tr> <td>${train.trainNo}</td> <td>${train.departStation} → ${train.arriveStation}</td> <td>${train.departTime} - ${train.arriveTime}</td> <td>¥${train.price}</td> <td>${train.remaining} 张</td> <td><a href="book.jsp?trainId=${train.id}">预订</a></td> </tr> </c:forEach> </table>这段 JSP 的细节在于把列表展示和跳转入口放在同一个表格里。${train.remaining}显示当前余票,但前端没有做售罄拦截,余票为 0 时“预订”按钮依然可以点,超卖检查完全靠后端第 3 章的事务逻辑扛。实战中我会先加一层:余票为 0 时把“预订”换成“已售罄”,用<c:if test="${train.remaining == 0}">条件渲染就能实现。表格里“出发站 → 到达站”用的是 HTML 字符串拼接。这里没转义风险,因为值来自初始化脚本的固定数据。如果以后改成用户自定义车次名,就要小心 XSS,输出前需要转义。5.2 局部异步刷新:少整页跳转,多 Ajax 联动首页最近的热门线路和订单状态这块是用 Ajax 做的局部刷新,不经过TrainSearchServlet,单独暴露了一个HotRoutesServlet,返回 JSON 数组,前端用 jQuery 的$.get拉取后拼到 DOM 上。$.get('hotRoutes', { city: 'Shanghai' }, function (data) { var list = $('#hot-routes'); data.forEach(function (item) { list.append('<li>' + item.depart + ' 到 ' + item.arrive + '</li>'); }); }, 'json');Servlet 返回 JSON 的常见做法是手动拼字符串,配合response.setContentType("application/json;charset=UTF-8")。这里有个容易翻车的点:手拼 JSON 必须自己处理引号转义和 null 值,一旦字段里带双引号就会解析失败。本项目是演示用所以没引 JSON 库,你要是想再加接口,建议直接上 fastjson 或 Gson,一行JSON.toJSONString(list)就能序列化整个车次列表。5.3 订单列表的状态回显与按钮联动订单页order_list.jsp读取当前用户的全部订单,每一行显示车次、票数、金额、下单时间和状态。状态字段status是数字,页面要做一次中文映射:0 显示“待支付”,1 显示“已支付”,2 显示“已取消”。常见做法是在循环里套<c:choose>。<c:choose> <c:when test="${order.status == 0}">待支付</c:when> <c:when test="${order.status == 1}">已支付</c:when> <c:otherwise>已取消</c:otherwise> </c:choose>不同状态还会触发不同的操作按钮:已支付订单显示“取消订票”,待支付订单显示“去支付”,已取消订单按钮置灰。这套联动逻辑全靠status一个字段判断,所以后端在更新状态时一定要保证事务一致性,否则页面会渲染出“已取消订单还能取消”这种自相矛盾的按钮。6. 二次开发实战:给车票系统加上“取消订票”并回补余票6.1 需求与接口:一个最小的状态变更闭环最常见的扩展需求是取消订票、改签、退票,本质上都是对t_order.status做流转,同时回补或调整t_train.remaining。这里以取消订票为例,讲一个最小闭环的改法。先在order_list.jsp的已支付订单后面加一个“取消”按钮,点击后 POST 到新增的CancelOrderServlet,携带orderNo。Servlet 内部做三件事:按订单号查订单、把status从 1 改成 2、把余票加回去。这三个动作必须在一个事务里,否则会出现“余票加了订单还是已支付”的脏数据。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String orderNo = request.getParameter("orderNo"); Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); Order order = orderDao.findByOrderNo(conn, orderNo); if (order == null || "2".equals(order.getStatus())) { throw new RuntimeException("订单不存在或已取消"); } orderDao.updateStatus(conn, orderNo, "2"); trainDao.increaseRemaining(conn, order.getTrainId(), order.getTicketCount()); conn.commit(); response.sendRedirect("order_list.jsp?msg=cancel_success"); } catch (Exception e) { conn.rollback(); request.setAttribute("errorMsg", e.getMessage()); request.getRequestDispatcher("order_list.jsp").forward(request, response); } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }这段代码里容易漏掉的是取消前的状态校验。订单已经取消过,再走一次取消逻辑会把余票重复加回去。这个幂等检查在真实项目里叫状态机守卫,很多接口的翻车事故都出在这里。6.2 验证方法与回归要点改完先别急着点按钮。我的验证流程分三步:第一步,用两个账号各下一单,确认订单数和余票数落在初始化脚本的基线值上;第二步,对同一订单连续点两次取消,第二次必须报“订单不存在或已取消”,同时数据库里remaining只增加了一次;第三步,开两个浏览器同时取消同一笔订单,看事务锁能不能拦住后进入的那个请求。三步都过了,再补页面提示:取消成功后把按钮置灰,用<c:if test="${order.status eq 1}">控制渲染,让用户知道状态已经不可逆。从那以后,我每次给带库存属性的业务加接口,都强制自己走一遍“先查状态机、再改数、再回归并发”的流程。这种裸写的 JSP 项目没有框架兜底,每一步都得自己画清楚,反而逼着人把事务边界和幂等性想明白。希望帮到你。本文还有配套的精品资源,点击获取