简介:这是一套基于 WEB 的车票预订信息系统设计与实现完整项目包,以 Java、Servlet、JSP 与 MySQL 为核心技术栈,覆盖从需求分析、数据库设计到前后端交互的完整开发链路,适合高校学生用于毕业设计、课程设计,也适合 Java Web 入门者对照学习。资源压缩包共 1337 个文件,大小约 55.82MB,文件类型覆盖 JSP/Servlet 页面与源码、Java 类文件、JS/CSS 前端资源、PNG/JPG/GIF 图片素材、JAR 依赖库、SQL 数据库脚本以及 MP4 操作演示视频等,目录结构清晰,能直观反映一个实际 Web 项目的组织方式。已有 1105 人学习下载,项目中车次查询、在线订票、订单管理等模块均有完整实现。通过该资源,学习者可获得可运行的项目源码、数据库表结构与初始化脚本、MyEclipse 环境配置说明,并结合演示视频复现关键操作流程,从而掌握 Java Web 应用从编码、部署到调试的完整方法,为独立开发类似信息系统打下坚实基础。
1. 基于WEB的车票预订信息系统:一个Java/JSP技术栈能直接跑的完整Web样本
做个Java Web项目最怕什么?不是写不出业务代码,而是拿到一套源码导入IDE后黑匣子一样跑不起来。这个基于WEB的车票预订信息系统,就是典型的JAVA车票预订管理系统课程设计/毕业设计形态,技术栈选的是 JSP + Servlet + JDBC + MySQL + Tomcat,没有 Spring Boot 包装,反而把 Java Web 的原始链路暴露得清清楚楚。登录、车次查询、在线订票、订单管理、后台车次维护,该有的业务闭环都有。适合两类人:一是正在做Java Web课设、需要一套能跑通能讲解的完整系统的人;二是想搞懂 JSP/Servlet 时代请求怎么进、Session 怎么管、事务怎么控的开发者,把前端页面到数据库的每一层都摊开看。
2. 系统框架拆解:JSP + Servlet + JDBC 三层架构与数据库设计
2.1 技术栈选型:为什么没上 Spring Boot,反而用 JSP 直出页面
这套系统没有走前后端分离,页面直接用 JSP 渲染,Servlet 做控制器,JDBC 访问 MySQL。现在回头看,这个技术选型不是过时,而是非常适合做教学样本:每一条请求链路都肉眼可见,没有框架自动装配帮你把细节吞掉。
从架构上看,它是典型的 MVC 变体:
- Model 层:JavaBean 实体类(User、Train、Order)+ DAO 数据访问类,负责和 MySQL 交互。
- View 层:JSP 页面,放在 WebRoot 或 webapp 目录下,用 JSTL 和 EL 表达式渲染数据。
- Controller 层:Servlet,接收请求、调用 DAO、决定跳转到哪个 JSP 或重定向到哪个 URL。
常见的设计是有一个 BaseServlet 做统一分发,子类只实现 doGet 和 doPost 里的业务分支,再用一个 Filter 统一处理编码和登录校验。
JSP 直出页面意味着表单提交、页面跳转、Session 取值这些基本功全部手写,对想搞懂“网页请求到底怎么到数据库再回来”的人来说,反而比直接上框架更友好。我一般建议刚接触 Java Web 的开发者先把这个项目跑通,再考虑换成 Spring Boot 版本,因为你在 JSP/Servlet 里踩过的每一个坑,都是框架帮你掩盖掉的问题。
2.2 目录结构:从 web.xml 到 DAO 的完整请求链路
典型的 Web 项目目录分成源码区和 Web 部署区两大部分。以 Eclipse/IDEA 布局为例,核心结构大致如下:
src/ ├── com.booking.filter │ ├── EncodingFilter.java │ └── LoginFilter.java ├── com.booking.servlet │ ├── UserServlet.java │ ├── TrainServlet.java │ └── OrderServlet.java ├── com.booking.dao │ ├── UserDao.java │ ├── TrainDao.java │ └── OrderDao.java ├── com.booking.entity │ ├── User.java │ ├── Train.java │ └── Order.java ├── com.booking.util │ ├── DBUtil.java │ └── Md5Util.java └── db.propertiesWebRoot / webapp 目录:
WebRoot/ ├── WEB-INF/ │ ├── web.xml │ └── lib/ ├── css/ js/ images/ ├── login.jsp register.jsp index.jsp ├── trainList.jsp book.jsp ├── orderList.jsp myOrders.jsp └── admin/ ├── manageTrain.jsp └── manageOrder.jsp这套结构最关键的一个点:所有 JSP 页面不直接访问数据库,必须通过 Servlet 中转。请求流程固定是“JSP 表单 → Servlet → DAO → MySQL → 返回 JSP 渲染”,谁绕过这个链路线路,谁就是在给自己挖坑。
web.xml 里最容易被忽略的是 Servlet 映射配置。很多新手把 URL 写错导致 404,本质是没搞懂 url-pattern 的匹配规则。
<servlet> <servlet-name>UserServlet</servlet-name> <servlet-class>com.booking.servlet.UserServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserServlet</servlet-name> <url-pattern>/user</url-pattern> </servlet-mapping>url-pattern 里配置的是浏览器访问的虚拟路径,比如/user,不是 Servlet 的类名。这里写错的话,Tomcat 启动不会报错,但你访问页面时会一脸懵地看到 404。Servlet 3.0 以后可以用 @WebServlet 注解替代 XML 配置,但这套老项目普遍用的是 web.xml 方式,改的时候注意别两种方式混用,否则容易出现 servlet 被加载两次的问题。
2.3 数据库设计:用户表、车次表、订单表怎么关联
数据库是这个系统的地基,表设计直接决定业务代码的写法。整套系统通常围绕三张核心表展开:
CREATE TABLE t_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(20), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_train ( train_id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL, start_station VARCHAR(50) NOT NULL, end_station VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, seat_count INT DEFAULT 0, ticket_price DECIMAL(10,2) DEFAULT 0.00 ); CREATE TABLE t_order ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, train_id INT NOT NULL, book_time DATETIME DEFAULT CURRENT_TIMESTAMP, ticket_num INT DEFAULT 1, total_price DECIMAL(10,2) DEFAULT 0.00, status TINYINT DEFAULT 0, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(user_id), CONSTRAINT fk_order_train FOREIGN KEY (train_id) REFERENCES t_train(train_id) );参数说明:status 字段一般用 0 表示已预订、1 表示已出票、2 表示已退票,用整型而不是字符串存状态,是为了后续扩展状态机方便;ticket_price 用 DECIMAL 而不是 FLOAT,是因为浮点数算金额会出现 0.1+0.2 不等于 0.3 的问题;t_user 的 password 存的是 MD5 摘要值,而不是明文,这个在登录校验时要注意。
三张表的关系很直观:用户和订单是一对多,车次和订单也是一对多。订单表通过外键关联用户表和车次表,查订单的时候要 JOIN 两张表拿到用户名和车次信息,这在 OrderDAO 里是最常见的查询逻辑。
3. 核心功能链路:登录、查询、订票背后的 Java 代码是怎么走的
3.1 登录模块:MD5 加密、Session 管理与过滤器拦截
登录是整个系统的入口,也是最值得逐行读代码的部分。流程并不复杂:用户在 login.jsp 输入用户名密码,表单 POST 到 UserServlet,Servlet 调用 UserDao 查库比对,成功则把用户对象塞进 Session,失败则携带错误信息返回登录页。
// UserServlet.java 核心登录逻辑 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("login".equals(action)) { String username = request.getParameter("username"); String password = request.getParameter("password"); // 密码先做 MD5 再比对,数据库里存的是摘要值 String md5Pwd = Md5Util.getMd5(password); UserDao userDao = new UserDao(); User user = userDao.findByUsernameAndPassword(username, md5Pwd); if (user != null) { // 登录成功:把用户信息写入 Session,跳转到首页 HttpSession session = request.getSession(); session.setAttribute("loginUser", user); response.sendRedirect("index.jsp"); } else { // 登录失败:转发回登录页并携带错误提示 request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } } }这段代码里有三个值得注意的设计点。第一,密码在 DAO 层比对前先做了 MD5 摘要,这意味着 SQL 语句里比较的是两个密文字符串,而不是明文密码;第二,登录成功用 sendRedirect 做重定向,失败用 forward 做转发,区别在于重定向会新建一次请求,避免刷新页面时重复提交表单;第三,Session 里存的是 User 对象而不是用户名 String,后续页面从 Session 取值时可以一次拿到所有用户信息。
有了 Session 还不够,得让未登录用户无法访问受保护页面。这个用登录过滤器实现:
// LoginFilter.java public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 直接放行登录页、注册页和静态资源 String uri = request.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("register.jsp") || uri.contains("/css/") || uri.contains("/js/") || uri.endsWith("/user")) { chain.doFilter(req, resp); return; } // 其余请求必须携带登录态 HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(req, resp); } else { response.sendRedirect("login.jsp"); } }注意这里用的是request.getSession(false),传 false 表示如果当前没有 Session 就直接返回 null,而不是创建一个空 Session。很多新手在这里直接调 getSession(),结果未登录用户访问页面时也会被动创建一个 Session,过滤器就形同虚设了。
3.2 车次查询:多条件组合拼接 SQL 与参数化防注入
车次查询是用户使用频率最高的功能,支持按出发地、目的地、出发日期三个条件组合查询。这里最核心的问题是:用户可能只填一个条件,也可能三个都填,SQL 语句必须动态拼接。
// TrainDao.java 多条件查询 public List<Train> searchTrains(String startStation, String endStation, String date) { StringBuilder sql = new StringBuilder("SELECT * FROM t_train WHERE 1=1"); List<Object> params = new ArrayList<Object>(); if (startStation != null && !startStation.trim().isEmpty()) { sql.append(" AND start_station = ?"); params.add(startStation.trim()); } if (endStation != null && !endStation.trim().isEmpty()) { sql.append(" AND end_station = ?"); params.add(endStation.trim()); } if (date != null && !date.trim().isEmpty()) { sql.append(" AND DATE(start_time) = ?"); params.add(date.trim()); } sql.append(" ORDER BY start_time"); return queryForList(sql.toString(), params); }这里的WHERE 1=1不是废话,它是个拼接技巧:先把条件全部用 AND 开头追加,不需要判断哪一个是第一个条件,代码简洁且不容易出错。参数全部通过 PreparedStatement 的占位符传入,而不是直接拼字符串,这是防 SQL 注入的根本手段。常见做法是在 Servlet 层也做一层参数校验,比如判断日期格式、限制字符串长度,双重保障。
DATE(start_time) = ? 这种写法能匹配到指定日期的所有车次,但它会让 start_time 上的索引失效,数据量大时查询会变慢。更优的方案是start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY),这个改造可以作为性能优化的一个切入点。
3.3 订票模块:事务保证余票扣减和订单生成不脱节
订票是整个系统里最能体现工程素养的模块,因为它涉及两张表的写操作:向 t_order 插入一条订单记录,同时更新 t_train 的余票数量。这两步必须在一个数据库事务里完成,否则会出现订单生成了但余票没扣、或者余票扣了订单没生成的尴尬局面。
// OrderDao.java 订票事务逻辑 public boolean createOrder(Order order) { Connection conn = null; PreparedStatement ps1 = null; PreparedStatement ps2 = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 开启事务,关闭自动提交 conn.setAutoCommit(false); // 第一步:检查余票 String checkSql = "SELECT seat_count FROM t_train WHERE train_id = ? FOR UPDATE"; ps1 = conn.prepareStatement(checkSql); ps1.setInt(1, order.getTrainId()); rs = ps1.executeQuery(); int seatCount = 0; if (rs.next()) { seatCount = rs.getInt("seat_count"); } if (seatCount < order.getTicketNum()) { conn.rollback(); return false; // 余票不足,回滚 } // 第二步:插入订单 String insertSql = "INSERT INTO t_order (order_no, user_id, train_id, ticket_num, total_price) VALUES (?, ?, ?, ?, ?)"; ps1 = conn.prepareStatement(insertSql); ps1.setString(1, order.getOrderNo()); ps1.setInt(2, order.getUserId()); ps1.setInt(3, order.getTrainId()); ps1.setInt(4, order.getTicketNum()); ps1.setBigDecimal(5, order.getTotalPrice()); ps1.executeUpdate(); // 第三步:扣减余票 String updateSql = "UPDATE t_train SET seat_count = seat_count - ? WHERE train_id = ? AND seat_count >= ?"; ps2 = conn.prepareStatement(updateSql); ps2.setInt(1, order.getTicketNum()); ps2.setInt(2, order.getTrainId()); ps2.setInt(3, order.getTicketNum()); int rows = ps2.executeUpdate(); if (rows == 0) { conn.rollback(); return false; // 扣减失败,余票不够,回滚 } // 全部成功,提交事务 conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(conn, ps1, rs); DBUtil.close(null, ps2, null); } }这个订票事务里有三个关键点,值得反复琢磨。第一,SELECT ... FOR UPDATE是行级锁,订票时锁定这趟车的记录,防止并发请求同时读到相同的余票数量;第二,扣减 SQL 里带了AND seat_count >= ?这个条件配合执行返回值判断,即使两个请求同时到达,也只有一个能扣减成功,这个技巧叫乐观锁思路,比先查再改要可靠得多;第三,setAutoCommit(false)之后,所有 SQL 都在同一个事务里执行,任何一步失败都整体回滚。
参数说明:seat_count是这趟车的总剩余座位数,ticket_num是本次订票的张数,total_price是单价乘以张数算出来的金额。这套逻辑如果单独看没问题,但对高并发场景来说还不够,更完善的方案是把余票字段放在独立的库存表里,再用 Redis 做缓存扣减,不过对课设和普通管理系统来说,这版事务已经算完整了。
3.4 后台管理:车次增删改和订单状态维护
后台管理模块是给管理员用的,功能集中在对 t_train 表的增删改查和订单查询。这个模块代码相对简单,但有一个容易漏掉的权限校验问题:管理员的 Servlet 路径是在登录过滤器的放行名单之外,所以任何没登录的用户访问后台 URL 都会被弹回登录页,但如果要把“普通用户不能访问后台”也管住,光靠登录过滤器不够,还需要额外校验登录用户的 role 字段。
车次新增的后台逻辑,典型的插入操作:
// TrainServlet.java 新增车次 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("add".equals(action)) { Train train = new Train(); train.setTrainNo(request.getParameter("trainNo")); train.setStartStation(request.getParameter("startStation")); train.setEndStation(request.getParameter("endStation")); train.setStartTime(request.getParameter("startTime")); train.setEndTime(request.getParameter("endTime")); train.setSeatCount(Integer.parseInt(request.getParameter("seatCount"))); train.setTicketPrice(new BigDecimal(request.getParameter("ticketPrice"))); TrainDao dao = new TrainDao(); boolean flag = dao.addTrain(train); if (flag) { response.sendRedirect("admin/manageTrain.jsp"); } else { request.setAttribute("errorMsg", "添加车次失败"); request.getRequestDispatcher("admin/manageTrain.jsp").forward(request, response); } } }这里最容易翻车的点是日期参数格式。JSP 页面上<input type="datetime-local">提交的值是2024-06-15T10:30这种带 T 的格式,而 MySQL 的 DATETIME 字段接收的是2024-06-15 10:30:00,两者无法直接对应,必须在 Servlet 里做一次字符串替换或格式化。很多新手在这直接报 SQL 异常,还以为是 SQL 写错了,实际上就是格式没转换。
订单管理里的一个实用功能是统计报表,比如按车次统计售票总数,SQL 大致是:
SELECT t.train_no, SUM(o.ticket_num) AS total_sold FROM t_order o JOIN t_train t ON o.train_id = t.train_id WHERE o.status = 1 GROUP BY t.train_no ORDER BY total_sold DESC;这种聚合查询能直接反映哪趟车卖得最好,做后台首页展示的时候特别有用。如果只是课程设计需要,也可以不做统计页面,但熟练写出这类 SQL 对答辩时加分有实实在在的帮助。
4. 部署运行:从 JDK 配置到 Tomcat 启动的完整步骤
4.1 环境准备:版本搭配与下载安装要点
这套项目跑起来需要四个基础软件:JDK、Tomcat、MySQL 和一个 IDE。版本搭配直接决定你能不能顺利启动,我建议按经典稳定组合来配:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | JSP/Servlet 老项目最稳妥的版本,Tomcat 8.5 原生支持 |
| Tomcat | 8.5.x | 对应 Servlet 3.1 / JSP 2.3 规范,兼容性好 |
| MySQL | 5.7 或 8.0 | 5.7 最省心,8.0 要注意驱动和时区配置 |
| IDE | IDEA 社区版或 Eclipse | 社区版免费,配 Tomcat 的方式略不同 |
| MySQL 驱动 | mysql-connector-java-5.1.49.jar | 5.7 用这个版本,8.0 要用 8.x 的驱动 |
安装 MySQL 时有个血泪经验:root 密码和端口一定要记住,项目里 db.properties 配置的就是这个账号密码。如果 MySQL 装的是 8.0,驱动类要从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver,url 还要额外加serverTimezone=Asia/Shanghai,不然报时区错误那是大概率事件。
4.2 数据库初始化:执行建库脚本与导入数据
项目压缩包里一般带有booking.sql或db_booking.sql文件,这就是数据库初始化脚本。用命令行或者 Navicat 执行一遍,就能把库表结构和初始数据全部建好。
mysql -u root -p < booking.sql执行脚本时最常见的报错是字符集问题,脚本里表的默认字符集如果是 utf8mb4,而你的 MySQL 客户端默认是 latin1,中文数据导入后就成了乱码。稳妥的做法是在执行前先执行一句 SET NAMES utf8mb4:
mysql -u root -p --default-character-set=utf8mb4 < booking.sql导入成功后,可以用SHOW TABLES;验证一下是否生成了 t_user、t_train、t_order 三张表。初始数据里一般会有一条管理员账号和几条车次数据,供后台登录和前台查询测试用。
4.3 修改连接配置:db.properties 与 JDBC 驱动
数据库建好之后,要改动的地方是这个db.properties文件,它放在 src 目录下,由 DBUtil 读取:
driverClassName=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/booking_db?useUnicode=true&characterEncoding=utf8 username=root password=123456 initialSize=5 maxActive=20这里每一项配置错了都会在启动后首次访问页面时才报错,而不是项目启动时立刻暴露,所以这个文件改完要仔细复核。url 里的characterEncoding=utf8是让 JDBC 连接用 UTF-8 和 MySQL 通信;如果 MySQL 版本是 8.0,url 里还必须有serverTimezone=Asia/Shanghai,否则连接会报“The server time zone value”的异常。
DBUtil 的常见实现是将配置读取和连接创建封装成静态方法,方便 DAO 层直接调用:
// DBUtil.java 连接池方式获取连接 public class DBUtil { private static DataSource dataSource = null; static { try { Properties props = new Properties(); InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties"); props.load(in); BasicDataSource ds = new BasicDataSource(); ds.setDriverClassName(props.getProperty("driverClassName")); ds.setUrl(props.getProperty("url")); ds.setUsername(props.getProperty("username")); ds.setPassword(props.getProperty("password")); ds.setInitialSize(Integer.parseInt(props.getProperty("initialSize"))); dataSource = ds; } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException e) {} } if (conn != null) { try { conn.close(); } catch (SQLException e) {} } } }这里用了 Apache Commons DBCP 连接池而不是 DriverManager,这是比较合理的做法。如果原项目里没有引入 DBCP 依赖,用 DriverManager.getConnection 也能跑,但每次请求都新建连接,高并发下性能就是灾难。
4.4 在 IDEA 中配置 Tomcat 并启动项目
IDEA 里跑 Web 项目,步骤固定在几条:把项目导入 IDEA,配置 Tomcat Server,添加 Artifact,然后启动。
具体操作路径大致如下:File -> Project Structure -> Artifacts,点加号选 Web Application: Exploded,配置好 Module 后把依赖的库加进 WEB-INF/lib;然后 Run/Debug Configurations -> Tomcat Server -> Local,把端口设成 8080,Deployment 标签页里把这个 Artifact 添加进去,Application context 设置为空或写/booking。
启动时注意看 Console 日志,如果出现“Port 8080 was already in use”,说明你的机器上已经有程序占了 8080 端口,换一个端口或者关掉占用程序都行。Tomcat 顺利启动后,日志最后会有一行Server startup in [xxx] milliseconds,看到这行就说明部署成功了。
浏览器访问http://localhost:8080/index.jsp,如果能打开首页,注册一个账号登录,查一趟车的余票,下单订票,这套流程就走通了。
4.5 验证运行:用一份测试用例走完核心链路
系统跑起来后,建议按这个顺序验证一遍,能快速暴露大部分问题:
验证点一是注册登录:注册一个用户,确认数据库 t_user 表新增了一条记录且密码是 MD5 密文,然后退出重新登录,确认能进首页且 Session 有效。验证点二是查询订票:用初始车次数据查一趟北京到上海的车,选 1 张票下单,然后去数据库看 t_order 多了订单、t_train 对应车次的 seat_count 减少了。验证点三是权限控制:不登录直接访问 orderList.jsp,应该被过滤器弹回登录页;用普通用户访问 admin/manageTrain.jsp,如果系统实现了角色校验就会提示无权限。这三个点都通过,系统核心功能就算稳定可用。
5. 踩坑排查:部署车票系统时最常遇到的5个问题
5.1 Tomcat 启动后立即报 ClassNotFoundException
现象:Tomcat 启动完,访问第一个页面就抛出java.lang.ClassNotFoundException: com.mysql.jdbc.Driver,控制台刷一大片异常。
原因:MySQL 驱动 jar 包没放进 WEB-INF/lib 目录。很多新手把 jar 包加在项目里就算完事,但 IDEA 里需要把 jar 添加到 Artifact 的 WEB-INF/lib 里,构建出的 war 包里才会包含它。
解决:Project Structure -> Artifacts -> 选中部署配置 -> 在 Available Elements 里找到你的 mysql-connector-java 驱动,右键选 Put into WEB-INF/lib,重新构建后重启 Tomcat。如果是 Eclipse,直接把 jar 复制到 WebContent/WEB-INF/lib 目录下,Eclipse 会自动部署。
5.2 页面能打开但乱码:JSP 页面和 Java 编码对不上
现象:登录页显示正常中文,但查询出来的车次名称、用户姓名全部变成“????”或一串乱码。
原因:三层编码不一致。JSP 文件本身的编码是 ISO-8859-1,Tomcat 拿到 GET 请求参数时默认按 ISO-8859-1 解码,而 MySQL 表结构是 utf8mb4,哪个环节断掉都会乱。
解决:JSP 页面第一行加上<%@ page contentType="text/html; charset=utf-8" pageEncoding="utf-8" %>;Filter 里添加:
request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8");再确认db.properties的 url 带上了characterEncoding=utf8。这三个设置齐全之后乱码问题基本就根治了。从那次排查之后,我每接到 JSP 老项目,第一件事永远是确认编码链路。
5.3 登录成功后跳回登录页:Session 被重新创建导致登录态丢失
现象:代码逻辑看着都对,数据库也能查到用户,但是登录成功进入首页,点任意一个子页面又被弹回 login.jsp。
原因:这个现象常见于没有用 getSession(false) 的过滤器里,或者在重定向后的 target JSP 页面里做了response.sendRedirect("ssoLogin.jsp"),把用户带到了另一个系统登录页。另一个原因是浏览器禁用 Cookie,而 Tomcat 的 Session 默认依赖 Cookie 里的 JSESSIONID。
解决:检查过滤器跳转逻辑,确定未登录时是不是直接回了 login.jsp;再用浏览器开发者工具看响应头里有没有 JSESSIONID 这个 Cookie。如果没有,在 Tomcat 的 context.xml 里加<Manager pathname="" />并关闭自动重写 URL,或者改用 URL 重写response.encodeURL()配合路径传 JSESSIONID。
5.4 发布到 Tomcat 后 404:访问路径和部署路径对不上
现象:本地 IDEA 里跑得好好的,把项目打成 war 包丢到 Tomcat webapps 下启动,访问http://localhost:8080/booking/index.jsp返回 404。
原因:war 包的名字决定了访问路径。如果你的 war 包叫booking.war,访问前缀就是/booking;如果叫ROOT.war,才能直接访问http://localhost:8080/。很多新手把 war 包名改来改去,前缀和访问路径就错开了。
解决:先确认 war 包文件名,然后按对应的前缀访问;或者把 war 包改名为 ROOT.war 部署,这样可以直接用根路径访问。旧的解压目录如果存在,删掉再重新部署,否则 Tomcat 不会覆盖老文件,你改的代码根本没生效。
5.5 订票成功但订单查不到:事务提交与查询时机不一致
现象:页面提示订票成功,t_train 的 seat_count 也减了,但去订单列表查不到刚才的订单,重新搜索车次余票又恢复原样了。
原因:SQL 执行是成功的,但事务没有真正提交。最常见的原因是项目里某处调用了conn.setAutoCommit(false)开启事务后,业务异常走了 rollback,但返回给前端的却是 success 状态;或者事务内执行了 update,但在提交前连接被关闭了,导致隐式回滚。
解决:在正常提交路径上加一个明确的conn.commit(),在 catch 块里确认打印了异常栈。另外检查是否是连接池的问题——某些连接池如果连接在事务中关闭,会自动回滚,需要确保 close 操作放在 commit 之后。
6. 二次开发方向:验证码、连接池和退票流程的改造实践
这套系统跑通一次之后,可以沿着几个方向改成自己的项目,答辩时也有实实在在的增量内容可以讲。
第一个方向是给登录加一个图形验证码,用 Java 原生 API 画图生成,Servlet 里把验证码字符串存进 Session,登录比对时先校验验证码再查用户。这个改造涉及的知识点很集中:BufferedImage 画图、Session 传递、前端刷新验证码的 img onerror 逻辑,难度适中但效果直观。
第二个方向是把 JDBC 换成 MyBatis 或者至少升级成连接池。前面 DBUtil 已经用了 DBCP,如果继续完善可以写一个分页查询的通用工具类。更进一步的改法是把 JSP 里的 Java 片段改成 JSTL 标签,把 DAO 层的 SQL 拆到 Mapper 里管理,代码瞬间清爽不少。
第三个方向是核心的退票流程。这套系统只有订票没有退票,业务闭环少了一环。改造需要做的是:在 t_order 加一个 status 字段的状态机流转,退票操作里开启事务,更新订单状态同时把 seat_count 加回去,为了防止超退,更新 SQL 里要带上status = 0的条件并校验影响行数。这个改造和订票事务是同一套思路,写一遍就会理解更深。
第四个方向可以是加一个简单图表统计页,用 ECharts 接收后台返回的 JSON 数据,展示各车次售票占比。这需要你把原来自动渲染的 JSP 改成 Ajax 接口返回 JSON,涉及 JSON 序列化工具(fastjson 或 Gson)的使用,也能让你从服务端渲染过渡到接口开发。
我自己的习惯是拿到一套 JSP/Servlet 老项目,先不急着改功能,而是按“编码 → 过滤器 → 事务 → 数据库”的顺序把每一层都核对一遍。从那以后我每次接手新项目都强制自己先画一遍请求链路,再动手写代码,这样能少走很多弯路。希望这个项目的解析能帮到你,欢迎下载源码对照着跑一遍,比光看文章收获大得多。
本文还有配套的精品资源,点击获取