ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSP+DAO+JDBC仿携程订票系统:源码拆解与部署改造指南

JSP+DAO+JDBC仿携程订票系统:源码拆解与部署改造指南 简介这是一个基于JSP技术实现的携程网功能仿制项目面向Java Web初学者和希望了解在线旅游平台内部流程的开发者。项目覆盖用户登录、信息查询、订单处理等核心业务体现了JSPServletJavaBean的经典开发模式能帮助读者理解MVC分层与前后端交互逻辑。资源共128个文件以Java源文件与编译后的class为主搭配JSP页面、JavaScript脚本、CSS样式及数据库SQL脚本同时包含MySQL配置、properties配置和若干图片动画资源压缩包整体约963KB目录结构清晰便于按代码、页面、脚本分类查阅。目前已有226人学习下载。借助该资源可动手还原旅行服务类Web应用的完整流程包括数据库建表、DAO封装、订单生成等环节配有项目配置文件与部分生成文件适合作为课程设计或毕业设计的参考资料。1. 携程山寨版 JSP 工程这不是源码包而是拆解后的图纸如果你拿到一个.rar压缩包里面躺着FlightInfoDaoJDBCImpl.class、TickerOrderDaoJDBCImpl.class、ConnectionFactory.class这些编译后的字节码加上几个救命的 JSP 页面第一反应大概率是这玩意儿能跑吗答案是能但你要有把它当“图纸”而不是“成品源码”的心态。这套资源本质是一个用 JSP JavaBean DAO 模式仿携程的在线票务工程覆盖了城市查询、航班查询、创建订单、订单列表这一整条主流程适合正在做 Java Web 课程设计、或者想快速看懂传统 JSP 项目怎么组织业务的开发者。它不是给你一个能直接上线的系统而是给你一套“别人已经趟过一遍”的代码骨架你照着搭建自己的系统比从零开始省掉不少工作量。2. 从类名反推架构DAO 模式下的订单与航班核心域2.1 文件清单里藏着什么五类职责一目了然把压缩包里的文件拉出来看虽然没有源码但类名已经把整个项目的职责划分说得明明白白。我按自己的习惯把这堆文件分成五组实体类、DAO 实现、数据库工具、页面文件、杂项。实体类有两个FlightInfo.class和TicketOrder.class。前者承载航班信息包括航班号、起降城市、时间、价格、余票这些字段后者是票务订单对应一次订票记录的完整信息。这两个类在 DAO 层和数据表之间充当传输对象JSP 页面通过它们展示数据。DAO 实现是重头戏CityInfoDaoJDBCImpl.class负责城市信息的增删改查FlightInfoDaoJDBCImpl.class管航班查询与更新TickerOrderDaoJDBCImpl.class处理订单落库。这三个 DAO 都以JDBCImpl结尾说明数据访问走的是原生 JDBC 而非 ORM 框架——这是老式 JSP 项目的典型做法代码直观但繁复每个方法都要自己管连接、Statement、ResultSet。数据库工具类有两个ConnectionFactory.class负责提供数据库连接IdGenerator.class生成订单编号。TestDao.class看名字就知道是开发期用来验证 DAO 是否正常的测试入口这个类在演示项目中经常被直接拿来做启动自检。剩下的两个文件比较特殊uiScript.asp和HotelCityAddressgb2312.aspx后缀和 Java 工程完全不搭。我判断这是打包时把目录里无关文件一起卷进来了放在第 5 章展开讨论。2.2 实体类与 DAO 的对应关系能看出表结构长什么样实体类是理解数据库结构的最佳入口。以FlightInfo为例它既然要承载航班信息对应表里必须有航班 ID、航班号、出发城市、到达城市、出发时间、价格、余票量、创建时间这些列。TicketOrder则对应订单表包含订单号、航班 ID、乘客信息、下单时间、订单状态。这两张表通过“航班 ID”建立关联订单表里的flight_id指向航班表的主键。整个系统的核心业务就建立在这样一个简单的关联关系上用户查航班 → 选定航班 → 生成订单。没有复杂的多级关联也没有中间表很适合用来做课程设计底子。在 JSP 页面里实体类通常以request.setAttribute(flightList, list)这种形式传给前端页面再用 JSTL 或scriptlet循环输出。看压缩包里的页面文件少得可怜说明整个项目把大量页面逻辑塞进了 JSP 本身走的是“瘦 Servlet 胖 JSP”的老路。如果你想改成 MVC 风格把 JSP 里的scriptlet替换成 EL 表达式和 JSTL 标签是第一件要做的事。2.3 IdGenerator 与 ConnectionFactory两个工具类的典型写法这两个类虽然代码量不大却是整个项目能跑起来的地基。ConnectionFactory的标准写法是静态代码块里加载驱动然后提供一个getConnection()方法给所有 DAO 复用。这是最常见的 JDBC 连接工厂模式我一般会建议在项目里加上连接池但在这种演示性质的项目里DriverManager直连已经足够。import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class ConnectionFactory { private static final String DRIVER com.mysql.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/ctrip?useUnicodetruecharacterEncodinggb2312; private static final String USER root; private static final String PASSWORD root; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这段代码的核心逻辑有三个Class.forName注册驱动、getConnection建立连接、把连接参数集中管理。注意characterEncodinggb2312这个参数它和页面声明的编码必须保持一致否则中文数据进数据库就会变成问号这是老 JSP 项目最容易翻车的地方后面避坑章会细说。IdGenerator的职责是生成不重复的订单编号不需要数据库序列纯内存生成。老项目里常见的方案是用时间戳加随机数再稳一点的做法是前缀加上毫秒时间戳再拼接一个内部计数器。import java.util.concurrent.atomic.AtomicLong; public class IdGenerator { private static final String PREFIX CT; private static final AtomicLong SEQ new AtomicLong(0); public static String nextId() { long now System.currentTimeMillis(); return PREFIX now String.format(%04d, SEQ.incrementAndGet() % 10000); } }这套生成器的特点是简单且单机不重复。AtomicLong保证并发下计数安全时间戳保证跨时间粒度不重复前缀方便在业务上区分订单来源。如果订单量很大这个方案会有瓶颈但在教学项目里完全够用。注意它生成的是字符串而非数字所以订单表的主键建议设计成varchar。3. 让流程先跑通从连接工厂到下单的 JDBC 链路3.1 一次订票请求走过的完整链路从用户点击“查询航班”到订单落库整个链路是这样一个过程JSP 页面提交表单 → 表单带着城市参数发给服务端处理 → 处理逻辑调用FlightInfoDaoJDBCImpl查询航班列表 → 返回ListFlightInfo→ JSP 循环渲染。用户选定航班后再提交订单 →TickerOrderDaoJDBCImpl插入订单记录。这里面最关键的是 DAO 层每个方法的实现质量。因为项目用的是原生 JDBC每个方法都要经历“拿连接 → 拼 SQL → 填参数 → 执行 → 解析结果 → 关资源”这六步。资源关闭特别容易踩坑.class文件里如果方法写得严谨应该能看到try-with-resources或finally里的关闭逻辑如果没有你二次开发时第一件事就是补这个。3.2 FlightInfoDao 的查询实现预编译语句怎么填航班查询是系统最核心的查询场景。按城市查航班SQL 长这样SELECT * FROM flightinfo WHERE from_city ? AND to_city ?。?是占位符通过PreparedStatement填充避免字符串拼接 SQL 带来的注入风险。这套写法在现在看是基本功但在早期的 JSP 教学项目里很多人习惯直接拼字符串留下了安全隐患。public ListFlightInfo queryByCity(String fromCity, String toCity) throws SQLException { String sql SELECT id, flight_no, from_city, to_city, depart_time, price, remain_tickets FROM flightinfo WHERE from_city ? AND to_city ?; ListFlightInfo result new ArrayList(); try (Connection conn ConnectionFactory.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, fromCity); ps.setString(2, toCity); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { FlightInfo info new FlightInfo(); info.setId(rs.getInt(id)); info.setFlightNo(rs.getString(flight_no)); info.setFromCity(rs.getString(from_city)); info.setToCity(rs.getString(to_city)); info.setDepartTime(rs.getTimestamp(depart_time)); info.setPrice(rs.getBigDecimal(price)); info.setRemainTickets(rs.getInt(remain_tickets)); result.add(info); } } } return result; }这个方法的参数说明fromCity和toCity是页面传入的城市名服务端在调用前最好做一次空值校验避免传 null 导致查询失效。价格字段用BigDecimal接收精确度比double好尤其是涉及金额计算时。余票量在展示和下单之后要做扣减这就在TickerOrderDaoJDBCImpl里配合事务一起处理了。3.3 下单方法订单插入这一步藏着事务边界下单是整个系统唯一涉及写操作的地方。它要做两件事插入订单记录、扣减航班余票。如果这两步不在同一个事务里极端情况下会出现“订单生成了但余票没扣”或反过来。老项目里常见的问题是插入订单和更新余票各开一个连接各自提交一旦中间抛异常数据就对不上。public boolean createOrder(TicketOrder order) throws SQLException { Connection conn null; try { conn ConnectionFactory.getConnection(); conn.setAutoCommit(false); String insertSql INSERT INTO tickerorder(order_id, flight_id, passenger_name, order_time, order_status) VALUES(?, ?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setString(1, order.getOrderId()); ps.setInt(2, order.getFlightId()); ps.setString(3, order.getPassengerName()); ps.setTimestamp(4, order.getOrderTime()); ps.setString(5, 已创建); ps.executeUpdate(); } String updateSql UPDATE flightinfo SET remain_tickets remain_tickets - 1 WHERE id ? AND remain_tickets 0; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, order.getFlightId()); int rows ps.executeUpdate(); if (rows 0) { throw new SQLException(余票不足); } } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }这段代码的注意点在最后一行conn.setAutoCommit(true)很多人会漏连接归还给连接池后自动提交状态被篡改下一个使用者就会踩坑。这里我的建议是手动事务必须保证autoCommit状态恢复否则一旦你后续引入连接池这个类就是事故源头。余票更新时加上remain_tickets 0条件用受影响行数判断是否超卖这个思路比先查再更靠谱。4. 部署到本机数据库、Tomcat 与编码三板斧4.1 数据库参数与建表脚本部署这套工程之前先把数据库准备好。从ConnectionFactory的 URL 能看出来库名是ctrip用户密码默认root/root字符集是gb2312。你本机 MySQL 如果密码不是这个记得改。建表脚本按第 2 章推断的表结构来三张表城市表、航班表、订单表。表名主要字段说明cityinfoid, city_name支持的城市列表页面下拉框数据源flightinfoid, flight_no, from_city, to_city, depart_time, price, remain_tickets航班主表余票量参与事务扣减tickerorderorder_id, flight_id, passenger_name, order_time, order_status订单表order_id 走向 IdGenerator 生成建库建表时直接用gb2312字符集保持整条链路编码一致不要建完库再用 UTF-8 去连会出乱码。CREATE DATABASE IF NOT EXISTS ctrip DEFAULT CHARACTER SET gb2312; USE ctrip; CREATE TABLE IF NOT EXISTS cityinfo ( id INT PRIMARY KEY AUTO_INCREMENT, city_name VARCHAR(50) NOT NULL ); CREATE TABLE IF NOT EXISTS flightinfo ( id INT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(20) NOT NULL, from_city VARCHAR(50) NOT NULL, to_city VARCHAR(50) NOT NULL, depart_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0, remain_tickets INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS tickerorder ( order_id VARCHAR(40) PRIMARY KEY, flight_id INT NOT NULL, passenger_name VARCHAR(50) NOT NULL, order_time DATETIME NOT NULL, order_status VARCHAR(20) DEFAULT 已创建 );cityinfo表现在看来功能弱但页面里城市下拉框的数据源就是它。如果你想给项目增加“最早/最晚航班排序”功能直接在flightinfo的depart_time字段上建索引就好。4.2 文件摆放、WAR 打包与 Tomcat 部署传统 JSP 项目的部署方式是把整个工程打成 WAR 包扔进 Tomcat 的webapps目录。你手里的文件一批是class字节码一批是 JSP 页面结构上需要按 Web 项目的标准目录重新摆放WEB-INF/classes下放编译后的 class 文件JSP 页面放在工程根目录或子目录。# 在工程根目录执行把当前目录打成 WAR 包 jar -cvf ctrip.war . # 复制到 Tomcat 的 webapps 目录 cp ctrip.war /usr/local/tomcat/webapps/ # 启动 TomcatWindows 用 startup.bat /usr/local/tomcat/bin/startup.sh # 跟踪启动日志看有没有报错 tail -f /usr/local/tomcat/logs/catalina.outjar -cvf是最常用的 WAR 打包方式c代表创建新包v输出过程f指定文件名。打进去之后 Tomcat 会自动解压解压出来就是一个可访问的工程目录。启动后先访问 JSP 页面而不是 index 页面因为工程里没有默认首页配置的话访问根路径可能直接 404。有一个方法能减少往返把ConnectionFactory里的数据库密码改成你自己的再编译替换。如果嫌麻烦就直接把 class 文件反编译出来改掉第 6 章有具体步骤。4.3 编码三板斧页面声明、数据库、连接参数必须一致这个项目明摆着是gb2312编码但你的开发环境默认大概率是 UTF-8两边一碰就是乱码。部署时按“三处强制一致”来检查第一处JSP 页面头部的pageEncoding和contentType在页面源码里找这两行。第二处ConnectionFactory的 JDBC URL 里的characterEncoding参数。第三处MySQL 建库时指定的字符集。这三处任何一处不一致表现出的乱码形态都不同页面中文碎成“锟斤拷”是 JSP 被存成 UTF-8 而pageEncoding声明成 gb2312数据库里存成???是连接参数没配上characterEncoding。Tomcat 的连接器也需要配合改。在server.xml的 Connector 节点上加URIEncodingGB2312不然 GET 请求带中文参数会失效。这是最容易被忽略的一层因为 POST 请求乱码可以通过request.setCharacterEncoding解决而 GET 请求编码完全由容器控制。Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingGB2312 /改完记得重启 Tomcat改server.xml不会热生效必须重启这一点很多人踩过。5. 这份资源最常踩的坑五种翻车现场与对应解法5.1 启动后查询直接报 SQLException连接被拒绝现象Tomcat 能起来JSP 页面能打开但只要一触发查询页面报Communications link failure或Access denied for user。原因绝大多数时候不是端口不对而是ConnectionFactory里的用户名密码和本机 MySQL 不一致或者 MySQL 服务本身没启动。我在 Windows 上遇到过localhost连不上、127.0.0.1能连上的情况是 MySQL 的 host 授权表里只配了 localhost而 jdbc 用了本机 IP。解决先用命令行工具验证账号能登录再改ConnectionFactory里的三件套。如果 MySQL 8还要确认驱动版本com.mysql.jdbc.Driver在 MySQL 8 里已经改名成com.mysql.cj.jdbc.Driver驱动类找不到也会报错。这个坑在旧项目里尤其常见因为老代码写在 MySQL 5.x 时代。5.2 数据能查出来但页面显示“锟斤拷”和问号现象页面标题正常但航班城市名和订单中文全部乱码数据库里查出来的数据带中文但页面渲染成乱码。原因JSP 页面保存时的文件编码和pageEncoding声明不符。大部分现代编辑器默认写文件是 UTF-8而老项目的pageEncoding很可能是gb2312浏览器按声明解析时就全乱了。解决统一用编辑器把 JSP 文件编码转成和页面声明一致的编码或者反向操作——把整个工程全部转成 UTF-8同时把server.xml里的URIEncoding改成 UTF-8、数据库和连接参数也统一改成 UTF-8。两条路选一条走到底最怕的就是页面用 gb2312 而数据库用 UTF-8两边都调不通。5.3 压缩包里混入 .asp 和 .aspx部署时它们怎么办现象把整个压缩包解压后直接扔进 webappsTomcat 报了一个奇怪的 404请求路径指向uiScript.asp。原因这两文件根本不是 Java Web 工程的产物大概率是某个 ASP.NET 站点的脚本被误打包进来了。Tomcat 对.asp后缀没有对应的 Servlet 映射会当作静态文件处理如果文件不存在或路径不对自然 404。解决直接删掉不影响工程运行。如果强迫症想确认它们有没有被用到在 JSP 和 class 文件里搜一下uiScript和HotelCityAddress搜不到就放心删。我拿到这类资源第一件事就是把明显不属于技术栈的文件隔离到备份目录防止 IDE 全目录扫描时被误导。5.4 下单成功后跳转 404订单其实已经写进库了现象点击确认订单按钮后页面报 404但数据库里查tickerorder表发现订单记录已经存在。原因这是 JSP 项目最典型的“写成功、跳转失败”。提交表单的 action 路径写的是相对路径而当前页面在二级目录下相对路径解析后指向了一个不存在的 JSP。订单插入已经在服务端执行完毕但页面跳转地址拼错了。解决先查数据库确认订单是否存在排除 DAO 层问题再检查表单的 action 路径。老 JSP 项目里最稳妥的方案是点击后跳到一个固定 Servlet 或直接response.sendRedirect(request.getContextPath() /orderList.jsp)不要用相对路径。经验是“先确认数据有没有进去再谈页面跳转”这样能快速切分前端和后端的问题边界。5.5 类加载报错NoClassDefFoundError 和 UnsupportedClassVersionError现象Tomcat 启动后访问页面报UnsupportedClassVersionError或者个别类加载失败抛NoClassDefFoundError。原因UnsupportedClassVersionError是 class 文件的编译 JDK 版本高于当前 JRE 运行版本。老项目拿 JDK 8 编译的 class 放进 JDK 11 的 Tomcat 里跑一般问题不大但反过来的情况会导致启动直接失败。NoClassDefFoundError则说明类之间有依赖没放全比如压缩包里漏了一个依赖 jar。解决用java -verbose或者直接看 class 文件的 major version 判断编译版本。javap -verbose FlightInfo.class | grep majormajor version 52对应 JDK 855对应 JDK 1161对应 JDK 17。拿到数字后给 Tomcat 配一个对应版本的 JDK或者重新从源码编译一次——但压缩包里没有 Java 源码所以优先选择是换 JDK 版本。6. 把 .class 还原成可读源码反编译与参数化改造这里分享一个我在拆这类资源时必做的动作反编译。压缩包里全是 class 文件直接读字节码不现实但用 CFR 反编译器能把它们还原成接近源码的 Java 文件读起来比javap输出友好得多。# 下载 cfr.jar 后执行把指定 class 反编译到 src 目录 java -jar cfr.jar TickerOrderDaoJDBCImpl.class --outputdir src # 对整个目录反编译 java -jar cfr.jar WEB-INF/classes --outputdir src_restored反编译出来的代码在注释和局部变量名上会有损失但业务逻辑完整可读。我的习惯是反编译后先不看实现细节直接搜所有Statement和字符串拼接的 SQL 痕迹——这一步能快速评估原作者的 SQL 注入风险程度。如果反编译结果里全是createStatement加字符串拼接那在接手改造之前优先级最高的就是把它们全部改成PreparedStatement。参数化字符串拼接是这类老项目最值得动刀的改造点。// 改造前反编译常见写法 String sql SELECT * FROM flightinfo WHERE from_city fromCity ; // 改造后 String sql SELECT * FROM flightinfo WHERE from_city ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, fromCity);改造的工作量集中在 DAO 层的查询方法上。实体类和工具类基本不用动连接工厂已经封装好唯一要加的是在 DAO 方法内部统一使用PreparedStatement并配合try-with-resources。项目改完想验证整体流程是否还通最快的方式是写一段独立的测试类直接调 DAO 方法而不是启动 Tomcat 走页面。public class QuickValidate { public static void main(String[] args) throws Exception { ListFlightInfo list new FlightInfoDaoJDBCImpl().queryByCity(北京, 上海); System.out.println(查询到航班数 list.size()); for (FlightInfo f : list) { System.out.println(f.getFlightNo() 余票 f.getRemainTickets()); } } }这段代码能跑通说明数据库连接、SQL 语法、实体类映射三层都没问题跑不通就往这三层里排查。从那以后我每次拿到这种老 JSP 资源都强制自己先反编译再写一个直连 DAO 的测试入口把页面层和数据库层彻底隔离开来确认底层链路没问题再去折腾 Tomcat 和编码。这套流程看着简单但确实能省掉大量“页面 500 但不知道错在哪”的排查时间希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进