简介:基于 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.class | JDBC 连接获取与关闭 | 全局 |
| 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 分就有解释了。演示时记得用无痕窗口分别开一个普通用户和一个管理员,把权限差异当场点给老师看,比你口头说一百句都有效。
本文还有配套的精品资源,点击获取