JavaWeb实战:基于JSP+Servlet+JDBC的火车票预订系统设计详解

📅 发布时间:2026/10/15 4:28:15
JavaWeb实战:基于JSP+Servlet+JDBC的火车票预订系统设计详解
最近总有人问起“课设选什么题目”我一般会先反问一句你JavaWeb的基础到底想练到什么程度如果答案是“想彻底搞懂请求从浏览器进去之后是怎么一步步查到数据库再渲染回页面的”那火车票预订系统这个题目确实是绕不开的经典。这个项目从标题就能看出技术栈比较明确Java JDBC JSP Servlet HTML MySQL说白了就是一套纯粹的JavaWeb全栈应用。它没有Spring、没有MyBatis所有HTTP请求处理、数据库访问、页面渲染都是程序员自己一行行写出来的。它能干什么往小了说是一个在线订票的网站往大了说它把注册登录、车次查询、余票判断、下单锁定、库存扣减这一整套真实业务系统的开发流程完整走了一遍。适合谁来学习我觉得三类人最合适一是正在做课程设计或毕业设计的在校生二是刚刚学完JavaWeb理论、想通过完整项目把Servlet和JSP串起来的新手三是想复习JDBC和SQL基本功、检验自己能不能不靠框架徒手写后端的人。这个博客就按照我做这类项目的实际经验从架构设计、数据库建表、核心功能代码、部署调试到常见坑位一层层拆开讲尽量让看完的人能照着把项目完整搭出来。1. 项目整体设计与技术选型为什么JSPServlet这套“老组合”依然值得写很多人在开始动手之前会纠结明明现在企业里都是Spring Boot了课设为什么还要用JSPServlet我理解这种顾虑但从学习角度看这个选型恰恰是它最大的价值所在。我之前帮人看过几个类似的项目最大的问题不是代码写不出来而是根本不知道请求是怎么流转的。用了Spring Boot之后一个RequestMapping注解就把URL映射到方法了一个Autowired就把Mapper注入进来了框架帮你把底层的请求分发、参数绑定、事务管理全部封装掉了。你要是顺着这个思路做课设做完之后脑子里除了“配置一下就能跑”之外什么都留不下。而JSPServlet这套组合你必须亲手处理web.xml里的Servlet映射、自己写doGet和doPost、自己从HttpServletRequest里取参数、自己管理HttpSession、自己用Connection开启事务。这些细节才是Web开发的地基。1.1 MVC三层结构在项目里的落地方式这个系统的代码组织方式不太需要花哨直接用最朴素的MVC三层来分就好网上所谓的三层架构、DAO模式、JavaBean实体类本质都是在围绕这一套转。Model模型层对应的是实体类和DAO类。实体类比如User、Train、Order负责把数据库表的字段映射成Java对象的属性DAO类比如UserDAO、TrainDAO、OrderDAO负责封装所有JDBC操作提供给上层调用。View视图层就是JSP页面和HTML静态资源。JSP负责展示动态数据比如车次列表、订单列表HTML负责呈现静态页面结构像首页、注册页的骨架。Controller控制器层是一堆Servlet。它们接收浏览器发来的请求解析参数调用DAO去查数据然后把结果放到request或session作用域里最后转发或重定向到JSP页面。我在实际项目中习惯给每个功能模块配两个Servlet比如登录注册拆成LoginServlet和RegisterServlet车次相关拆成SearchTrainServlet和BookTicketServlet订单相关拆成OrderListServlet和CancelOrderServlet。每个Servlet只干一件事命名直观一点后面维护和答辩都省事。1.2 为什么这个选型最适合理解Web运行原理做这个项目的时候你会被迫去面对几个框架已经替你解决的问题URL是怎么和Java代码对应上的表单提交的数据到底存在哪用户登录状态是怎么保持住的页面上的数据是怎么从数据库里“流”出来的举个例子。在Spring Boot里你写个RequestMapping(/login)就完事了但在JSPServlet项目里你必须在web.xml里配一段映射servlet servlet-nameloginServlet/servlet-name servlet-classcom.train.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameloginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping这段配置看起来很啰嗦但映射关系大白于天下浏览器访问/login这个路径时Tomcat读到web.xml找到对应的Servlet类实例化、调用service方法再根据请求类型分发到doPost或doGet。这种“手动挡”的体验开过自动挡的人可能觉得麻烦但正因为麻烦你才真正记住了Web容器的工作机制。还有Session。在JSP里session是一个内置对象直接拿过来用就行。很多新手以为这就是JSP自带的功能其实背后是Tomcat根据JSESSIONID这个Cookie在维护会话状态。你把这个逻辑跑通一遍后面学Spring MVC时就能理解HttpSession为什么到处都能取到。2. 数据库设计火车票系统的核心是余票和订单写代码之前先建库建表这个顺序千万别搞反。火车票预订系统作为一个典型的“库存交易”型应用数据库表设计直接决定了后面业务逻辑写起来是顺滑还是别扭。我看过不少半途而废的项目问题大多出在表结构没想清楚就上手写SQL结果到实现下单功能时发现字段缺东少西只能反复改表。这个系统最核心的表我觉得有三张用户表、车次表、订单表只要把这三张表设计好整个业务就撑起来了。为了贴合“真实感”我还习惯在订单表里加一个状态字段用不同的数字表示“已支付”“已取消”等让业务逻辑更完整。2.1 核心表结构与建表SQL实战用户表字段不必贪多但有两个字段要注意一个是password一个是id_card。密码字段不能明文存储至少要用MD5加盐处理一下身份证号是购票实名制的关键信息查订单的时候要按它来关联所以也别漏掉。CREATE DATABASE train_system DEFAULT CHARACTER SET utf8mb4; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );车次表是系统的核心资源表注意把余票数量单独作为一个字段放进表里。为什么不根据订单表动态统计因为简单项目里动态计算会让每次查询都要count订单表涉及连表操作性能差不说逻辑还容易出错。直接用remaining_tickets字段记录余票下单时扣减退票时加回去直观高效。CREATE TABLE trains ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL UNIQUE, start_station VARCHAR(50) NOT NULL, end_station VARCHAR(50) NOT NULL, depart_time VARCHAR(10) NOT NULL, arrive_time VARCHAR(10) NOT NULL, price DECIMAL(10,2) NOT NULL, total_tickets INT NOT NULL, remaining_tickets INT NOT NULL );订单表要特别强调一下order_no这个字段它是一个唯一的订单编号我习惯用时间戳加随机数生成比如202501011200001234这样。让用户下完单之后可以拿着这个编号查订单而不是暴露自增id。CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) NOT NULL UNIQUE, user_id INT NOT NULL, train_id INT NOT NULL, ticket_count INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-已支付 1-已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_train FOREIGN KEY (train_id) REFERENCES trains(id) );在建表语句里我加了两条外键约束这是有讲究的。有些教程怕麻烦会省略外键理由是后续删除数据会受限制。但我个人建议课设项目保留外键因为数据库层面的完整性约束可以让你的数据不容易出现“订单挂在没有的用户上”这种脏数据答辩时老师问起来也更有说法。2.2 关键字段设计背后的逻辑与避坑思路火车票预订系统里最容易翻车的地方是余票字段的并发安全。我见过有同学这样写扣减余票的代码先查一次remaining_tickets判断大于0再执行update trains set remaining_tickets remaining_tickets - 1 where id ?。单用户下单没问题但如果两个用户同时订最后一趟车的票两个请求都读到余票为1都以为自己能订结果实际上只扣一次库存超卖就发生了。作为课设项目不用上太高深的分布式锁但至少要在SQL层面把这个问题考虑进去。正确的做法是把判断和扣减合并成一个原子操作UPDATE trains SET remaining_tickets remaining_tickets - 1 WHERE id ? AND remaining_tickets 0;如果这条SQL执行后影响的行数是1说明扣减成功影响行数是0说明余票不足或已经被别人抢走。这就从源头避免了“先查后改”的并发漏洞。这块我会在后面下单功能的代码里再完整演示一遍在这种小项目里把问题考虑到这里已经很能体现工程意识了。还有一个细节是价格字段。金额别用float或double数据库里用DECIMAL(10,2)。Java侧对应使用BigDecimal不然小数点后的误差在累计订单金额时会非常妖。3. 核心功能实操路径与代码细节表结构设计好之后就可以进入编码阶段了。我习惯按照“工具类优先、实体类其次、DAO随后、Servlet和JSP最后”的顺序来写这样每个环节都能单独测试不会等所有代码写完才发现底层方法有问题。3.1 数据库连接工具类JDBC的起点写DAO之前必须把DBUtil写好。这个工具类负责加载驱动、获取连接所有DAO都通过它拿到Connection对象。注意数据库连接的URL里一定要加上时区和编码参数不然容易出现“连接成功但中文乱码”或者“时区报错”的问题。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/train_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这里有个小细节很多人喜欢直接用连接池但课设项目里用原生DriverManager就够了代码量最少、最容易理解也能让你搞清楚连接从哪里来、到哪里去。如果你已经掌握了JDBC基础想提升点难度可以把DBUtil改成基于C3P0或Druid连接池的版本但本质逻辑不变属于锦上添花。3.2 用户注册与登录的实现要点注册和登录看似简单其实牵扯到表单校验、密码加密、Session管理、请求转发一堆东西。先说注册。用户在register.jsp填完用户名、密码、姓名、身份证号后表单提交到RegisterServlet。Servlet里先判断用户名是否已存在存在就返回提示不存在才执行插入。因为users表的username字段设了唯一索引即使程序逻辑漏判数据库也会挡住重复数据。密码这块我强烈建议别用纯MD5虽然课设里很多教程只写个MD5(password)但这样做一旦数据库泄露反查非常容易。我习惯的做法是加盐public static String md5WithSalt(String password, String salt) { String base password salt; return DigestUtils.md5Hex(base.getBytes(StandardCharsets.UTF_8)); }盐就用username或者随机字符串都行。虽然对课设来说有点“杀鸡用牛刀”但养成分层思考的习惯对以后工作是有好处的。登录的实现核心是Session的写入。用户输入用户名密码后LoginServlet调用UserDAO.findByUsernameAndPassword查到了说明验证通过把用户信息放进sessionHttpSession session request.getSession(); session.setAttribute(user, user); session.setAttribute(userId, user.getId()); response.sendRedirect(index.jsp);注意这里用的是sendRedirect而不是forward。原因是登录成功后浏览器地址栏需要变成新的地址同时要重新发起一次请求避免用户刷新页面时表单重复提交。如果用了forward地址栏还是login刷新一次就再登录一次体验很怪。还有一个很容易被忽略的点每次都要校验用户是否登录过。很多同学做完登录之后发现直接在浏览器里输入orderList.jsp的地址居然能看到别人的订单。这就是因为JSP页面没有做登录拦截。最简单的方案是写一个LoginFilter对所有需要保护的路径做过滤没登录就重定向到登录页public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpSession session req.getSession(false); if (session null || session.getAttribute(user) null) { ((HttpServletResponse) response).sendRedirect(login.jsp); return; } chain.doFilter(request, response); } }在web.xml里把这个Filter映射到/orderList、/bookTicket等需要登录才能访问的路径即可。这个小动作非常提分基本属于“没做也行但做了就显得专业”的范畴。3.3 车次查询功能的SQL解析与PreparedStatement防注入车次查询这个功能看着简单但要做得像样也有文章可做。比如用户想查“从北京到上海”的车SearchTrainServlet接收startStation和endStation两个参数调用TrainDAO.findTrains方法public ListTrain findTrains(String start, String end) throws SQLException { String sql SELECT * FROM trains WHERE start_station ? AND end_station ?; ListTrain list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, start); ps.setString(2, end); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Train train new Train(); train.setId(rs.getInt(id)); train.setTrainNo(rs.getString(train_no)); train.setStartStation(rs.getString(start_station)); train.setEndStation(rs.getString(end_station)); train.setDepartTime(rs.getString(depart_time)); train.setArriveTime(rs.getString(arrive_time)); train.setPrice(rs.getBigDecimal(price)); train.setRemainingTickets(rs.getInt(remaining_tickets)); list.add(train); } } } return list; }用PreparedStatement而不是Statement核心原因就是防SQL注入因为预编译的参数不会被当作SQL语句执行。很多踩坑的同学喜欢用字符串拼接SQL一旦用户在输入框里输入 OR 11之类的值查询条件就变成永真式整个表都被查出来了。这在真实系统中是绝对不允许的低级漏洞在课设中也要主动避开。车次查询还可以拓展一个功能按出发时间筛选。比如用户选了“上午出发”就在SQL里加AND depart_time 12:00。这种需求本质就是动态SQL课时有限的话不必做太复杂但用StringBuilder拼条件时一定要记得参数还是用?占位。3.4 下单预订核心流程事务、余票扣减与订单生成下单是火车票预订系统最核心的功能也是我见过出错最多的环节。把这个功能的流水线理清楚比死记代码强得多。用户点击“预订”按钮后BookTicketServlet要做这几件事从Session里取出userId从请求参数里取出trainId和购票数量ticketCount。查询这趟车的信息计算总价。判断余票是否足够。扣减余票。生成订单记录。这里最大的坑在第3步和第4步之间。如果余票检查和扣减是两条独立的SQL在高并发场景下就会出现超卖。我前面已经提到SQL层面的解决办法现在把整段事务代码展示出来。使用事务的关键是同一个Connection上先关闭自动提交执行完所有操作后统一提交任何一步失败就回滚。Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); String deductSql UPDATE trains SET remaining_tickets remaining_tickets - ? WHERE id ? AND remaining_tickets ?; try (PreparedStatement ps conn.prepareStatement(deductSql)) { ps.setInt(1, ticketCount); ps.setInt(2, trainId); ps.setInt(3, ticketCount); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); request.setAttribute(error, 余票不足下单失败); request.getRequestDispatcher(searchResult.jsp).forward(request, response); return; } } String insertSql INSERT INTO orders (order_no, user_id, train_id, ticket_count, total_price, status) VALUES (?, ?, ?, ?, ?, 0); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setString(1, generateOrderNo()); ps.setInt(2, userId); ps.setInt(3, trainId); ps.setInt(4, ticketCount); ps.setBigDecimal(5, price.multiply(BigDecimal.valueOf(ticketCount))); ps.executeUpdate(); } conn.commit(); response.sendRedirect(orderList); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); request.setAttribute(error, 系统异常请稍后重试); request.getRequestDispatcher(error.jsp).forward(request, response); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这段代码里有三个点值得说明。第一setAutoCommit(false)必须在同一个连接下执行不能getConnection()两次。第二扣减余票时把remaining_tickets ?写在WHERE条件里这一步同时完成了“判断余票是否充足”和“扣减余票”两个动作前面说过的超卖问题就是这样防住的。第三订单号和订单金额都必须由后端生成绝不能让前端传来的价格直接入库否则用户可以改价格下单。订单编号的生成我一般这样写private String generateOrderNo() { return System.currentTimeMillis() String.valueOf(System.nanoTime()).substring(8); }简单、唯一性高、不用查数据库确认。虽然看起来有点土但在单机课设场景足够了。3.5 订单列表与取消订单的实现要点订单列表功能就是把当前登录用户的所有订单查出来展示在orderList.jsp上。这里要注意的是需要连表查询因为订单表里存的是trainId但页面上要展示车次号、始发站、终点站等车次信息。SELECT o.id, o.order_no, o.ticket_count, o.total_price, o.status, o.create_time, t.train_no, t.start_station, t.end_station, t.depart_time FROM orders o JOIN trains t ON o.train_id t.id WHERE o.user_id ? ORDER BY o.create_time DESC别把这条SQL复杂化直接JOIN就好。在OrderDAO里写一个方法把查询结果封装成OrderVO对象。所谓VO就是View Object专门给页面上展示用的里面既有订单信息又有车次信息。很多同学一开始只会用一个实体类走天下遇到这种多表查询就不知道怎么封装其实新建一个组合类就行了这是很朴素也很实用的做法。取消订单的本质是把订单状态改成已取消同时把车票库存加回去。这里同样需要事务更新订单的status为1更新车次的remaining_tickets加回ticketCount。注意判断一下订单当前状态如果已经是取消状态就不能再重复取消否则库存会多加一次。这就是“幂等性”的意思——同一个操作执行多次结果必须一致。4. 环境搭建与部署避坑实录很多同学代码写得没问题但最后卡在“本地怎么跑起来”这一步。这个项目涉及JDK、Tomcat、MySQL、IDE四个环境版本之间相互配合稍有差错就报各种奇奇怪怪的错。我把我踩过的坑和验证过的稳定配置方案写一下你们可以直接照着抄。4.1 版本选型与开发环境配置先说JDK。我用的是JDK 8别嫌老这个项目只需要Java 8的特性就完全够用。而且Tomcat 8或Tomcat 9对JDK 8的支持都非常成熟不会有版本兼容的烦恼。如果你机器上装的是JDK 17也不是不行但建议把编译级别调到Java 8避免某些语法或库行为差异带来的不必要困扰。MySQL用5.7或者8.0都行但要注意驱动版本。MySQL 5.7对应驱动用com.mysql.jdbc.Driver老版本或者com.mysql.cj.jdbc.Driver新版本也可以兼容MySQL 8.0则必须用com.mysql.cj.jdbc.Driver且URL里要带serverTimezone参数。很多同学遇到“找不到驱动类”的报错十有八九是驱动jar包没放进WEB-INF/lib目录或者放的位置不对。开发工具我在IDEA和Eclipse之间都有试过。如果你用Maven很熟练可以在IDEA里建一个Maven项目把依赖写在pom.xml里省得手动导jar包。但如果是课设答辩很多老师要求你现场能跑通我建议用最简单的Dynamic Web Project结构手动把mysql-connector-java的jar包扔到WEB-INF/lib下面这样项目在另一台电脑上部署的时候不会因为Maven仓库缺失而抓狂。4.2 从零跑通项目的完整步骤我按最传统的Eclipse或IDEA流程讲一下。第一步新建一个Dynamic Web Project项目名随意比如train-ticket-system。第二步把DBUtil.java、所有实体类、DAO类、Servlet类按照包名结构放好。第三步在WebContent目录下创建JSP页面和WEB-INF/web.xml。第四步右键项目选择Run As - Run on Server前提是Tomcat已经配置好。Tomcat的配置有两个细节。第一Server Location不要勾选“Use workspace metadata”直接选“Use Tomcat installation directory”这样部署的目录结构更清晰网页文件直接能覆盖替换。第二如果8080端口被占用可以在server.xml里把端口改到8081或9090报Port 8080 required by Tomcat ... is already in use的时候不要慌改端口就行。数据库准备这块先用Navicat或命令行执行建库建表SQL再插入一些测试数据。车次的测试数据可以自己编比如INSERT INTO trains (train_no, start_station, end_station, depart_time, arrive_time, price, total_tickets, remaining_tickets) VALUES (G101, 北京南, 上海虹桥, 08:00, 13:00, 553.00, 1000, 1000), (G102, 北京南, 上海虹桥, 12:00, 17:30, 553.00, 1000, 1000);插入测试数据时一定要保证remaining_tickets和total_tickets一致不然页面上刚打开就显示余票不足容易误判是程序写错了。4.3 高频报错排查速查表我整理了实际部署中遇到频率最高的几类问题把现象、原因、解决方案都列出来了大家可以按图索骥。报错现象可能原因解决方案启动Tomcat时报端口被占用8080端口被其他进程占用打开cmd执行netstat -ano找PID杀掉进程或修改Tomcat端口后重启运行页面时中文乱码JSP页面编码和数据库编码不一致JSP文件头部加% page contentTypetext/html;charsetUTF-8 %数据库连接URL带characterEncodingutf8ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动jar包没放到WEB-INF/lib把mysql-connector-java的jar包复制到WEB-INF/lib目录Communications link failureMySQL端口不是3306或服务没启动检查MySQL服务是否运行确认端口号后修改DBUtil里的URLTable train_system.users doesnt exist连错了数据库检查URL里的数据库名是否和实际库名一致java.lang.NullPointerException页面取值时Session或Request中数据为空检查Servlet是否把数据set进了request或session作用域确认变量名与JSP中EL表达式一致订单扣了库存但订单表没有记录事务回滚了但页面提示又没处理好检查事务提交逻辑注意commit()放在最后所有SQL执行成功才能提交这个表格看着简单但每一条都是真实项目里反复遇到过的。尤其是中文乱码发生的场景很多有响应输出乱码、有请求参数乱码、有数据库存储乱码定位方式也各不相同。我的个人经验是先统一项目所有文件的编码为UTF-8再在JSP头部声明编码最后检查数据库连接URL这三层都做了基本不会再乱码。4.4 JSP页面与小部分前端联调心得JSP页面是这个项目的脸面即使后端逻辑全部正确页面丑得没法看也会被扣分。我不建议在这上面堆什么花哨的前端框架而是把精力花在数据展示的准确性和页面的可用性上。用JSTL标签库配合EL表达式来遍历列表代码会比纯% %脚本片段干净很多。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table tr th车次/thth出发站/thth到达站/thth出发时间/thth到达时间/thth价格/thth余票/thth操作/th /tr c:forEach items${trainList} vartrain tr td${train.trainNo}/td td${train.startStation}/td td${train.endStation}/td td${train.departTime}/td td${train.arriveTime}/td td${train.price}/td td${train.remainingTickets}/td tda hrefbookTicket?trainId${train.id}预订/a/td /tr /c:forEach /table注意${trainList}这个变量名必须和Servlet里request.setAttribute(trainList, list)设置的key完全一致这是新手最常见的低级错误之一。还有在JSP里写a标签链接时参数用?拼接中文参数需要URLEncoder.encode转码否则查询中文站名时会有编码问题。我建议页面里尽量少写Java代码能用EL就用EL能用JSTL就用JSTL。如果不会JSTL也可以用% %但会显得比较老派。把数据展示和业务逻辑分离这也算MVC思想在页面层的体现。5. 进阶扩展方向与我的经验总结项目做到这里功课已经算完成了。但如果想把品质再拔高一点让答辩时更有底气我建议可以做两件“低成本高回报”的扩展。第一个扩展是加一个简单的图表统计。比如在后台展示“热门车次TOP5”的柱状图数据来源就是从订单表里GROUP BY train_id后统计订单数。推荐用ECharts或者Chart.js前端引一个JS文件后端提供JSON数据接口一个简单的Servlet输出JSON即可。这会让整个项目看起来不只是一个CRUD而是一个有数据可视化的信息管理系统。第二个扩展是把密码加密从MD5升级为BCrypt加盐。只需要在UserDAO里改两行代码却能体现你对安全性的理解。很多同学做项目时根本没想到密码要加密你不仅做了还用上了BCrypt这在课设答辩里是很明显的加分项。第三个扩展是引入线程池或者连接池。把DBUtil里的DriverManager.getConnection换成Druid连接池的DataSource配置一个druid.properties文件就行。这不需要改太多业务代码但代表了你知道“每次请求都新建物理连接”的做法在生产环境下是不可接受的。这些扩展我都建议在核心功能稳定跑通之后再做。搞课设最怕的是贪多求全一上来就想把所有功能都做了最后每个功能都是半成品。我的经验是先把“注册、登录、查车次、下订单、看订单、取消订单”这条主链路跑得滚瓜烂熟再做任何锦上添花的事。5.1 从实际项目中学到的关键认知做一个完整的JSPServlet项目对我个人而言最大的收获不是学会某种具体技术而是终于把Web开发这棵树的“主干”摸清楚了。以前看Spring Boot教程很多概念像DispatcherServlet、HandlerMapping、ViewResolver挂在嘴边但不懂原理做完这个项目再回头看Spring MVC发现它做的无非就是把我手写的那些Servlet映射、请求转发、视图解析自动化了而已。这就是基础项目的价值——它让你知道框架帮你了什么你才真正懂得框架宝贵在哪。还有一个认知上的升级是关于“代码该分层的时候一定要分层”。初学阶段很容易把所有逻辑都堆在Servlet里比如直接在doPost里写JDBC代码看起来代码很短但后续维护时到处找问题。后来我养成了“Servlet只管请求解析和流程控制DAO只管数据库操作实体类只管数据载体”的习惯哪怕项目只有几百行代码也坚持这个分层习惯。坚持一段时间后你会发现排查Bug的思路清晰了很多页面数据不对先看Servlet有没有set对值数据库查不到数据先看DAO里SQL写的对不对而不是在一个几百行的Servlet里来回翻。我经常说判断一个JavaWeb练手项目是否合格的唯一标准不是功能多不多、界面炫不炫而是你能否在没有框架辅助的情况下完整地讲出“一次订票请求从点击按钮到数据库发生变化再到页面回显”的完整链路。如果你心里对这条链路已经有了清晰的画面这个项目的目的就达到了。