JSP+Servlet+JDBC机票预订系统:数据库设计与事务实战解析
2026/9/14 1:32:00 网站建设 项目流程

简介:基于 Java + MySQL 实现的机票预订系统项目源码,适合计算机、通信、人工智能、自动化等相关专业的学生用于期末课程设计、大作业或毕业设计,也适合刚入门 Java Web 的开发者参考学习。项目为个人大作业,答辩评审分达 98 分,核心代码均经过调试测试,可正常运行;整体结构清晰,包含 jsp 页面、java 后端逻辑、css/js 前端样式与交互、sql 数据库脚本等,便于对照理解前后端联动与数据库操作。压缩包共 210 个文件,包体约 4.51MB,主要类型涵盖 jsp、java、class、css、js、png 及 sql 等;其中 png 图片多为界面截图或流程示意,sql 文件为建表与初始数据,md 文件可辅助快速了解项目结构。已有 67 人学习下载,适合用于课程设计参考、业务功能扩展练习或 Java Web 综合实战训练,基础较好的读者也可在现有航班管理、订单处理、会员留言等模块上继续二次开发。

1. 为什么这套机票预订系统值得拆开看

这套系统的类名很有意思:db_conn、check_login_reg、search_fly、deal_order、deal_message,一眼就能看出是典型的 JSP+Servlet+JDBC 三层结构。虽然现在很多课设已经切换到前后端分离页面,但这种传统写法的价值在于把 HTTP 请求到数据库记录的完整链路放在明面上,适合拿来弄清 Session 管理、PreparedStatement 防注入、事务回滚这些面试必考点。答辩拿到 98 分的项目,代码调试过能直接跑通,尤其适合期末大作业、课程设计和毕设起步阶段。基础好的可以改一版加个管理员统计报表,或者把 JDBC 换成 MyBatis,改造成本也不高。想找 java+mysql 项目实战源码做参考的话,这套的模块划分是很好的样本。

2. 从class文件反推系统架构与MySQL表设计

2.1 类名就是模块边界

拿到源码先别急着跑,把编译后的 class 清单过一遍,基本就还原了功能列表。这里逐一看一下:db_conn 管数据库连接,flight 是航班实体,check_login_reg 处理登录注册,search_fly 做查询,addFlight 是管理员添加航班,check 是校验,deal_order 处理下单,deal_message 处理留言,del_user_message 删除留言,upload_file 做文件上传。模块映射如下表:

class 文件推断职责涉及表
db_conn.classJDBC 连接获取与关闭全局
flight.class航班实体与 Bean 属性flight
check_login_reg.class登录/注册表单校验与 DB 操作user
check.class通用校验(空值、格式、权限)多表
search_fly.class多条件航班查询flight
addFlight.class管理员新增航班flight
deal_order.class创建订单、扣减余票orders / flight
deal_message.class提交留言message
del_user_message.class按权限删除留言message
upload_file.class文件上传(头像/附件)视需求而定

这个划分不是随手拆的:每个模块只做一件事,DB 操作独立出来,页面只负责渲染。答辩时护住一个点就行——你用了 Model2 架构,请求进 Servlet,Servlet 调 DB 操作类,结果放 request 域转发到 JSP,没有让 JSP 直接写 SQL,这比把 JDBC 代码塞在 JSP 里高出不止一个台阶。

2.2 MySQL 表结构:订单表为什么要存冗余字段

数据库按需求拆四张表,外加一张可选的附件表。下面是我按这类课设最常见需求补的建表语句,可对照调整:

CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 'MD5加盐后的密文', role TINYINT NOT NULL DEFAULT 0 COMMENT '0-普通用户 1-管理员', real_name VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE flight ( id INT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(10) NOT NULL UNIQUE, from_city VARCHAR(30) NOT NULL, to_city VARCHAR(30) NOT NULL, dept_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, remain_tickets INT NOT NULL DEFAULT 0 ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, flight_id INT NOT NULL, flight_no VARCHAR(10) NOT NULL COMMENT '冗余快照', price DECIMAL(10,2) NOT NULL COMMENT '下单时价格快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已出票 3-已取消', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (flight_id) REFERENCES flight(id) ); CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_deleted TINYINT NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES user(id) );

orders 表里特意冗余了 flight_no 和 price,而不是只存 flight_id。原因是航班可能有改价、改时刻的操作,订单作为历史凭证必须保留下单那一刻的快照。答辩时老师问“为什么订单表不用外键查航班表”,你就答:为了抗业务变更,订单和金额以快照为准。这是数据库设计里很实用的取舍。

2.3 db_conn 的连接管理:从 DriverManager 到连接池的过渡

db_conn 类承担所有 DAO 的资源获取,最常见写法是静态代码块注册驱动,每次操作获取新连接:

public class db_conn { private static final String URL = "jdbc:mysql://localhost:3306/airline?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 标准关闭逻辑,注意逆序关闭并判空 } }

连接参数里有几处不能乱动:characterEncoding=utf8 是中文入库不乱码的前提;serverTimezone 在 MySQL 8.x 下缺了会报时区错误;useSSL=false 是本地开发避免证书告警。如果用的是 mysql-connector-java 8.x,驱动类要写成 com.mysql.cj.jdbc.Driver,这排在入门踩坑榜很靠前。要是嫌每次 new 连接太慢,可以换成 Druid 连接池,把 getConn 改成从 DataSource 取,改动只集中在 db_conn 一个类,其他代码完全不用动。DriverManager 和连接池的取舍见下表:

对比项DriverManager 直连Druid 连接池
实现成本低,适合课设需要引入 jar 与配置
并发能力差,每请求新建连接连接复用,抗压
维护点无监控可看活跃连接数、慢 SQL
答辩加分基础实现能答出“连接复用”就加分

3. 登录校验与航班查询:JDBC 的读写边界

3.1 登录态:Session 的作用域和失效策略

check_login_reg 把登录和注册放在一起,说明大概率是两个方法共用同一个入口。登录链路是:取 username/password → 密文比对 → 查 user 表 → 成功把用户对象放 Session → 重定向到主页;失败回登录页并带 error 消息。注册则多一步看用户名是否已存在。这里有个容易被忽略的点:用户密码不要明文存,课设阶段用 MD5 加盐即可,盐用固定串或者用户名倒序都行:

String salt = "airline_2024"; String cipher = DigestUtils.md5Hex(salt + password); String sql = "SELECT id, username, role FROM user WHERE username=? AND password=?";

为什么用 PreparedStatement 而不是字符串拼接,除了防注入,还有一个好处:MySQL 会对带参数的 SQL 做服务端预处理,同样结构的查询第二次执行更快。答辩如果被问到 SQL 注入,直接拿这段代码说“参数化查询让数据库把 ? 当值处理,而不是拼进 SQL 结构”,基本就过关了。

Session 的超时时间建议在 web.xml 里显式配置成 30 分钟,默认值在生产环境可能偏短或偏长。在 Session 里保留 role 字段也很有用:后面 addFlight、del_user_message 都要靠它判断管理员权限,不用每次查库。

3.2 search_fly:多条件动态 SQL 怎么安全拼接

航班查询是列表页的核心。输入起降城市、出发日期,可选的还有航班号。最忌三种写法:无条件拼 where 1=1、用 Statement、把日期当成字符串直接比较。推荐的做法是先构造可变条件集:

public List<Flight> search(String from, String to, String date, String flightNo) { StringBuilder sql = new StringBuilder("SELECT * FROM flight WHERE 1=1"); List<Object> params = new ArrayList<>(); if (from != null && !from.trim().isEmpty()) { sql.append(" AND from_city LIKE ?"); params.add("%" + from.trim() + "%"); } if (to != null && !to.trim().isEmpty()) { sql.append(" AND to_city LIKE ?"); params.add("%" + to.trim() + "%"); } if (date != null && !date.trim().isEmpty()) { sql.append(" AND DATE(dept_time) = ?"); params.add(date); } if (flightNo != null && !flightNo.trim().isEmpty()) { sql.append(" AND flight_no = ?"); params.add(flightNo.toUpperCase()); } sql.append(" ORDER BY dept_time"); // 使用 PreparedStatement 循环填充 params,再执行查询 }

这段代码的价值在于参数与 SQL 分离:params 里的每个值都会走 setString 绑定,即便用户输入' or 1=1 --也只是被当成普通字符串,不会改变 SQL 结构。注意日期过滤用DATE(dept_time) = ?,这样即使库里的字段带时间部分,也能精确匹配某一天。排序直接 ORDER BY dept_time,对应到高频需求上:如果想按价格升序,可以加一个ORDER BY price ASC的开关变量,让前端传 sort 参数。

3.3 check 类:后端校验不能只防前端

upload_file 和 addFlight 都有输入数据,check.class 的作用就是兜底。前端可以用 JS 校验必填,但攻击者绕过页面直接 POST 是轻而易举的事,所以 Servlet 里必须再次校验。常见做法是抽一个静态方法统一做 String 判空、日期格式和数字范围检查:

public static boolean validateFlight(String flightNo, String from, String to, String deptTime, String price, String remain) { if (flightNo == null || flightNo.trim().isEmpty()) return false; if (from == null || to == null || from.trim().isEmpty() || to.trim().isEmpty()) return false; if (deptTime == null || !deptTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}")) return false; try { if (Double.parseDouble(price) < 0) return false; if (Integer.parseInt(remain) < 0) return false; } catch (NumberFormatException e) { return false; } return true; }

校验规则的粒度可以参考这张表:

字段校验条件说明
flight_no非空,长度≤10航司代码+数字,如 MU5123
dept_time必须匹配 yyyy-MM-dd HH:mm解析失败直接拒绝
price大于等于 0,最多两位小数用 BigDecimal 更严谨
remain_tickets非负整数防止负数库存
content(留言)非空,长度≤500超长截断或报错

另外要注意:校验失败时返回的错误信息不要带数据库异常细节,统一提示“参数格式不正确”即可。把 SQLException 的 message 直接抛给用户,等于把库表结构告诉对方。

4. 下单、留言与添航班:事务处理和权限控制

4.1 deal_order:余票扣减必须和订单插入在同一个事务

下单是这个系统里最容易出 bug 的地方。假设用户提交订单时还有 5 张余票,你查了余票没问题,但还没插入订单时另一个请求把余票扣到了 0,于是超卖。课设中最稳的是在事务里用条件 UPDATE 扣票:

UPDATE flight SET remain_tickets = remain_tickets - 1 WHERE id = ? AND remain_tickets > 0;

这条 SQL 没影响行数说明库存已经不足,立刻回滚。配合事务模板,把“扣余票”和“插订单”绑在一起:

Connection conn = db_conn.getConn(); try { conn.setAutoCommit(false); // 1. 条件扣减余票 PreparedStatement ps1 = conn.prepareStatement( "UPDATE flight SET remain_tickets = remain_tickets - 1 WHERE id = ? AND remain_tickets > 0"); ps1.setInt(1, flightId); int affected = ps1.executeUpdate(); if (affected == 0) { conn.rollback(); throw new RuntimeException("余票不足"); } // 2. 插入订单 PreparedStatement ps2 = conn.prepareStatement( "INSERT INTO orders(order_no, user_id, flight_id, flight_no, price, status) VALUES(?,?,?,?,?,0)"); // order_no 用时间戳+随机数生成,保证唯一 ps2.executeUpdate(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); db_conn.close(conn, null, null); }

事务关键点:setAutoCommit(false) 之后必须手动 commit,任何一步失败都要 rollback;finally 里要恢复 true,避免连接归还池子后状态残留。前提是 MySQL 用 InnoDB 引擎,MyISAM 不支持事务和行级锁,这也是为什么建表时不要随意改成其他引擎。

4.2 addFlight:管理员操作要过 Filter,不能只靠页面隐藏

如果新增航班的 Servlet 没有权限校验,任何人直接 GET /addFlight 就能往库里塞数据。正确姿势是抽一个登录过滤器,对所有 /admin/* 路径做角色校验:

public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpSession session = request.getSession(false); Object role = session == null ? null : session.getAttribute("role"); if (role != null && (Integer) role == 1) { chain.doFilter(req, resp); } else { ((HttpServletResponse) resp).sendRedirect(request.getContextPath() + "/login.jsp"); } }

在 web.xml 里把 /admin/* 映射到这个 Filter,addFlight 的 URL 就放在 /admin/addFlight 下。这样权限判断和业务逻辑解耦,不用在每个 Servlet 开头重复写。注意 session.getSession(false) 是有意为之:不会为未登录用户主动创建 Session,省掉无谓的开销。

4.3 留言的两种删除:物理删除还是逻辑删除

deal_message 是提交留言,del_user_message 是删除留言。删除这里有个设计选择:直接 DELETE 还是 UPDATE is_deleted:

方式实现优点缺点
物理删除DELETE FROM message WHERE id=?简单,省空间误删无法恢复
逻辑删除UPDATE message SET is_deleted=1 WHERE id=?可恢复,保留用户行为查询都要带 is_deleted=0 条件

课设里建议用逻辑删除,答辩时能说“考虑到留言可能有审计需求,我选择保留原始数据”。删除权限判断要同时满足两个条件之一:留言的 user_id 等于当前登录用户 id,或者当前用户 role=1。用一条 SQL 就能写:

DELETE FROM message WHERE id = ? AND (user_id = ? OR EXISTS (SELECT 1 FROM user WHERE id = ? AND role = 1));

这条 SQL 把权限收敛到数据层,业务里只需要传当前用户 id。受影响行数为 0 时统一提示“无权删除或留言不存在”,避免暴露具体原因。

4.4 upload_file:文件上传的安全边界

如果项目里有上传头像或批量导入航班 Excel 的功能,upload_file 要注意两点:扩展名白名单和大小限制。用 Servlet 3.0 的 Part 接口处理:

Part part = request.getPart("file"); String submittedFileName = part.getSubmittedFileName(); String ext = submittedFileName.substring(submittedFileName.lastIndexOf('.') + 1).toLowerCase(); if (!Arrays.asList("jpg", "png", "gif", "xlsx", "xls").contains(ext)) { throw new IllegalArgumentException("不支持的文件类型"); } if (part.getSize() > 5 * 1024 * 1024) { throw new IllegalArgumentException("文件不能超过 5MB"); }

注意不要直接用用户提交的文件名保存到磁盘,要自己生成 UUID 文件名,再单独保存原始文件名映射。否则路径穿越和恶意文件覆盖会让项目在答辩当场翻车。上传目录建议放到应用外部或受保护目录,不要把可执行文件传到能解析的目录下。

5. 答辩前必须调好的三个细节

5.1 统一编码 Filter 与 GET/POST 差异

中文乱码是评审老师必测的点。POST 请求乱码要在取参之前执行 request.setCharacterEncoding("UTF-8");GET 请求的乱码来自 Tomcat 默认 URIEncoding,要在 server.xml 的 Connector 上加 URIEncoding="UTF-8"。最省事的做法是写一个编码过滤器,对所有请求统一设置:

request.setCharacterEncoding("UTF-8"); response.setContentType("text/html; charset=UTF-8"); chain.doFilter(request, response);

页面、数据库、连接串三端都用 UTF-8,整条链路就一致了。

5.2 给高频查询加索引并用 EXPLAIN 验证

search_fly 最常用的条件是出发城市、到达城市、日期。除航班号已有唯一索引外,城市和日期可以补一个复合索引:

ALTER TABLE flight ADD INDEX idx_city_time (from_city, to_city, dept_time);

然后执行 EXPLAIN 看查询是否走索引:

EXPLAIN SELECT * FROM flight WHERE from_city = '北京' AND to_city = '上海' ORDER BY dept_time;

如果 type 列是 ref 或 range,而不是 ALL,就说明索引生效了。答辩时把这个拿出来说,能显著拉高印象分,证明你不仅写通了功能,还考虑了数据量大时的查询性能。

5.3 准备好这三个问题的两分钟答案

评委大概率会问三连:登录态怎么保持?SQL 注入怎么防?下单超卖怎么处理?对应答案压缩成:

  • 登录后用 HttpSession 存用户 id 和 role,后续请求从 Session 取,不需要每次查库;退出时 invalidate。
  • 所有 SQL 一律用 PreparedStatement 参数绑定,不让用户输入直接参与 SQL 拼接。
  • 下单时在同一事务里执行“条件 UPDATE 余票”和“INSERT 订单”,余票不足则影响行数为 0,直接回滚。

这三个问题答完,再把订单表冗余快照的表结构拿出来解释一遍,你的 98 分就有解释了。演示时记得用无痕窗口分别开一个普通用户和一个管理员,把权限差异当场点给老师看,比你口头说一百句都有效。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询