JavaWeb课程设计实战:在线旅游网站从环境搭建到部署避坑全解析
2026/9/24 19:19:33 网站建设 项目流程

简介:一份面向JavaWeb课程设计与期末大作业的在线旅游网站项目,内含完整源代码、数据库脚本及配套文档说明。项目基于Java服务端技术,实现了旅游线路展示、关键字搜索、路线详情等常用模块,并整合Bootstrap等前端资源,页面风格统一,适合计算机相关专业学生用于课设、毕设或项目初期演示。压缩包共1870个文件,大小约70.29MB,其中以1753张JPG图片(页面截图及素材)为主,辅以39个Java源文件、12个HTML、10个CSS、7个JS文件,同时包含SQL数据库脚本、Eclipse项目配置文件及字体图标资源,目录结构清晰,便于按需查阅与导入开发环境。已有756人浏览学习,代码经过测试运行成功,可直接作为课程作业参考;资源内附文档说明,可帮助初学者理解项目结构与实现思路,也适合在此基础上扩展功能、完成二次开发。

1. 在线旅游网站的 JavaWeb 课程设计:这份源代码和文档说明到底值不值得信

期末前两周才打开老师给的题目,是 JavaWeb 课程设计最常见的时间线。这个标题下面的东西其实分两部分:一份能在 Tomcat 里跑起来的在线旅游网站源代码,和一份能讲清设计思路的文档说明。它的价值不在于代码量有多大,而在于你不用从 Servlet 接口开始抠,改改名字、换换业务,就能把它变成能答辩的作业。适合三类人:第一次做 JavaWeb 课程设计的、时间不够又不想整段复制的、答辩时怕被老师问住的人。接下来我会按一条复现路线来讲:环境 → 建库 → 登录 → 线路列表 → 下单收藏 → 避坑 → 验收,每步都能直接落地。

2. JavaWeb 项目完整案例 MySQL 起步:先把环境、建表和项目骨架一次配好

一个能跑的项目,80% 的功夫在环境而不是代码。新电脑上我配过不下十次 IDEA 的 JavaWeb 环境,每次套路都差不多。这一章先把三条主线走完:导入或创建项目、MySQL 建库、Maven 依赖,这三步不出错,后面写代码基本一路绿灯。

2.1 在 IDEA 里跑通 JavaWeb 项目:三个必不可少的运行配置

无论你用的 IDEA 是 2023 还是最新的 2026,New Project 向导再怎么改版,只要注意三个地方,成功率都很高。第一是项目导入方式:课程设计通常给的是一个压缩包,解压后如果是 Maven 项目,直接 File → Open 选到 pom.xml 那一层;如果给的是纯 Web 目录,里面没有 pom.xml,就在 IDEA 里新建一个普通 Java 项目,把 src 目录整体拷进去,再右键 Project Structure → Modules → 加 Web 模块。第二是 SDK 版本:项目用的 JDK 要和你本地 Tomcat 支持的版本一致,一般 JDK 8 或 11 最稳,别为了追求新装个 JDK 21 去跑 Tomcat 9,容易在类加载阶段冒出奇怪问题。第三是 Tomcat 配置:Run → Edit Configurations → 加 Tomcat Server → Local,在 Server 页签选本地 Tomcat 目录,然后到 Deployment 页签点加号,选 Artifact → 后缀为 war exploded 的那个。

这里有一个最容易被忽略的细节:Deployment 页签里的 Application context。默认值是一长串,建议改成/travel。它决定整个项目在浏览器里的访问路径,也决定了 JSP 页面里所有静态资源引用的开头。如果留空,后面 index.jsp 里引用 CSS 的路径会全部对不上,页面会变成一堆没有样式的文字。JSP 里引用资源时,不要写死成/travel/css/xxx,课程设计一旦换了 context path,全站资源全部 404。更稳的写法是动态拼接:

<link rel="stylesheet" href="${pageContext.request.contextPath}/css/common.css"> <script src="${pageContext.request.contextPath}/js/index.js"></script>

这段代码把项目根路径动态拼到资源路径最前面。${pageContext.request.contextPath}在 JSP 环境里等于 Application context 里填的值,部署到任意路径下都能自适应。它和直接写/travel/...的效果一样,但前者可以跨目录部署,后者改一次路径要改十几个文件。运行时如果 Tomcat 启动成功但访问是 404,先看 IDEA 控制台的信息:出现 “Artifact is being deployed” 和INFO: Server startup in xxx ms两行日志,说明部署完成;如果只有启动日志没有部署日志,多半是 Artifact 没配好,回去检查 Deployment 页签。常见做法是给 Tomcat 单独指定一个干净的 CATALINA_BASE,把之前残留的旧项目清掉。JDK 8 以上的项目用@WebServlet注解即可,不需要额外配 web.xml,这一点在文档说明里写清楚能省很多事。

2.2 数据库脚本先落地:五张表把业务锁住

在线旅游网站的核心表不复杂,常见做法是用户表、线路表、订单表、收藏表。消息公告要不要加一张,看课程设计文档说明里有没有“新闻公告”功能要求,没要求就不要自己加,表越多后期改起来越痛苦。下面这段 SQL 我整理过,库名、字段名和注释都按课程设计文档常用的习惯写,MySQL 5.7 和 8.0 都兼容:

CREATE DATABASE IF NOT EXISTS travel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE travel; CREATE TABLE t_user ( uid INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码(MD5 后存放)', nickname VARCHAR(30) COMMENT '昵称', phone VARCHAR(11), email VARCHAR(50), activate TINYINT DEFAULT 1 COMMENT '1 正常,0 禁用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE t_route ( rid INT PRIMARY KEY AUTO_INCREMENT, rname VARCHAR(100) NOT NULL COMMENT '线路名称', price DECIMAL(10,2) NOT NULL COMMENT '人均价格', route_intro TEXT COMMENT '行程介绍', stock INT DEFAULT 100 COMMENT '剩余可预订名额', rdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '上架时间', rflag TINYINT DEFAULT 1 COMMENT '1 上架,0 下架', rimage VARCHAR(200) COMMENT '封面图相对路径' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='旅游线路表'; CREATE TABLE t_order ( oid INT PRIMARY KEY AUTO_INCREMENT, uid INT NOT NULL, rid INT NOT NULL, order_time DATETIME DEFAULT CURRENT_TIMESTAMP, people INT DEFAULT 1 COMMENT '出游人数', contact_name VARCHAR(30) COMMENT '联系人', contact_phone VARCHAR(11) COMMENT '联系电话', pay_status TINYINT DEFAULT 0 COMMENT '0 未支付,1 已支付', CONSTRAINT fk_order_user FOREIGN KEY (uid) REFERENCES t_user(uid), CONSTRAINT fk_order_route FOREIGN KEY (rid) REFERENCES t_route(rid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE t_favorite ( uid INT NOT NULL, rid INT NOT NULL, fav_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (uid, rid), CONSTRAINT fk_fav_user FOREIGN KEY (uid) REFERENCES t_user(uid), CONSTRAINT fk_fav_route FOREIGN KEY (rid) REFERENCES t_route(rid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';

建表脚本里有两个参数值得专门说明。一是字符集统一用utf8mb4,不要只写utf8。MySQL 的utf8最多存 3 个字节,而 emoji 和生僻字要占 4 个字节,在线旅游网站里用户昵称一旦带 emoji,用utf8的表会直接写入报错;二是密码字段留 64 位,配合 MD5 或 SHA-256 这类摘要算法存放,不要建 20 位,课程设计里密码明文入库是答辩老师最高频指出的问题。接着初始化一点数据,方便登录和首页展示:

INSERT INTO t_user(username, password, nickname, phone) VALUES ('admin', MD5('123456'), '管理员', '13800000000'); INSERT INTO t_route(rname, price, route_intro, stock, rimage) VALUES ('云南大理丽江 5 日游', 3280.00, '洱海骑行、玉龙雪山、丽江古城', 80, 'images/dali.jpg'), ('三亚纯玩 4 日游', 2580.00, '亚龙湾、蜈支洲岛、海鲜大餐', 50, 'images/sanya.jpg'), ('川西稻城亚丁 6 日游', 3680.00, '新都桥、稻城亚丁、高原湖泊', 60, 'images/daocheng.jpg');

初始化数据里用MD5('123456')而不是直接写死一串散列值,好处是文档说明里写“默认账号 admin / 123456”时别人能对上,自己换密码也方便。注意不同 MySQL 版本的 MD5 函数返回结果一致,这点放心。

2.3 项目骨架与 Maven 依赖:不引框架也能让代码不散装

课程设计阶段引 Spring 全家桶是冒险行为:一来答辩老师大概率会问“Spring 帮你做了什么,你自己写了什么”,答不好反而扣分;二来 Maven 解析依赖耗时,教室没外网时卡在下载这里很难看。纯 JSP + Servlet + JDBC 是最稳的组合,代码量不大,每个环节都能说清楚。Maven 的 pom.xml 里,我一般只保留这几个依赖:

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>

版本号不必死盯这一组,能拉到即可,用 8.0.x 和 1.2.x 的后续版本也可以。重点说明两处:javax.servlet-api的 scope 必须是provided,因为 Tomcat 自己带 Servlet 实现,带上它会和容器里的类冲突,导致一些莫名其妙的 NoSuchMethodError;Druid 负责连接池,它的细节先不用深究,课设阶段知道它比 DriverManager 手动管理连接更稳定、也更合答辩口味就行。包结构我习惯这样组织,不硬套三层架构,但一定要拆出清晰的层:

src/main/java/com/example/travel ├── domain -- User、Route、Order、Favorite 的 POJO ├── dao -- JDBC 数据访问,只负责 SQL ├── service -- 业务逻辑,含事务 ├── servlet -- 所有控制器,继承 BaseServlet ├── filter -- 编码过滤、登录拦截 └── util -- DbUtils、MD5Utils

dao层和service层分开是 JavaWeb 教学案例里的常规动作,哪怕 service 层只是简单转调,也建议留出来。答辩被问“为什么这么分层”时,你能说出“dao 管数据怎么存,service 管业务规则和事务,servlet 只做参数接收和页面跳转”,这一句话的印象分比多写十个接口都值钱。util里放个 Md5Utils,所有密码统一走 md5 后再入库,也是避免散装代码的细节。

3. 从登录到线路列表:把在线旅游网站的核心功能用代码串起来

环境跑通、表建好之后,剩下的都是体力活。这一章不看前端样式,只看链路:浏览器发请求 → servlet 接收 → dao 查库 → service 处理 → JSP 渲染。把这条链路走顺,后面加任何页面都只是复制粘贴。

3.1 用 BaseServlet 做请求分发:不用再写一堆 doGet/doPost

一个在线旅游网站至少有登录、注册、退出、线路列表、线路详情、下单、收藏这 7 个操作。如果每个功能都重写 doGet 和 doPost,代码里全是重复的转发逻辑。常见的轻量方案是写一个 BaseServlet,让所有业务 Servlet 继承它,通过 URL 上的 method 参数来定位要执行的方法。

import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String methodName = req.getParameter("method"); if (methodName == null || methodName.isEmpty()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少 method 参数"); return; } try { Method method = this.getClass() .getMethod(methodName, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(HttpServletResponse.SC_NOT_FOUND, "找不到对应方法"); } catch (Exception e) { throw new ServletException("方法调用失败", e); } } }

这段代码的核心不是反射本身,而在于统一了请求入口。子类里写一个public void login(HttpServletRequest, HttpServletResponse),URL 里带method=login,框架就会自动执行这个方法。它把 doGet/doPost 的样板代码压缩成一个方法名参数,新功能只需要加方法,不用改任何转发配置。需要注意getMethod只能找到 public 方法,所以业务方法必须写成 public,方法签名要和反射调用一致。有些同学把方法写成 private,或者多写了一个参数,启动时不报错,一访问就提示“找不到对应方法”。出现这种问题先重新编译,再检查 URL 里的参数名是否拼对。具体业务 Servlet 上用@WebServlet("/route")注解,子类不会被父类影响。

3.2 登录校验与 Session:为什么登录状态不能只靠 JSP 判断

登录是最常见的踩坑点,不少教学案例直接在 login.jsp 里嵌<% if ("admin".equals(...)) %>,这种写法虽然能演示,但根本挡不住别人直接访问内部页面。正确做法是让登录逻辑走 Servlet,再把用户信息写进 Session,由 Filter 统一做拦截。LoginServlet 的核心方法:

public void login(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { req.setAttribute("msg", "用户名或密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } User user = userService.login(username.trim(), Md5Utils.md5(password)); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } req.getSession().setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/route?method=list&currentPage=1"); }

这段代码有几个细节值得在文档说明里写清楚。第一层校验是参数非空,这是后端必做的,不要指望前端表单一定可靠;第二层校验交给 service,密码在进 SQL 之前已经用 md5 做了一次摘要,数据库里始终不出现明文;第三层是 Session 的写入时机,只有在 user 不为 null 时才写入,失败时用请求转发带着 msg 回到登录页。注意最后一行用了sendRedirect而不是forward,这是防止“刷新后重复提交登录表单”的关键,后面第 4 章还会用到同一个原则。Session 里存整个User对象而不是布尔标志,原因很实际:页面右上角要显示昵称,订单页要拿 uid 做关联,把那几个字段分开存,Session 里会堆一堆 key。对应的登录拦截 Filter 长这样:

@WebFilter("/user/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

这段代码的拦截范围只对/user/*路径生效,所以受保护页面的访问路径要按这个前缀组织,例如/user/orderList.jsp放在 WebContent 下的 user 目录里。getSession(false)不会创建新 Session,未登录用户直接跳登录页;已登录用户正常放行。如果路径组织乱了,这个 Filter 会拦截不到一部分页面,出现“明明要求登录才能看,不登录也能打开”的漏洞。

3.3 首页线路列表与分页:JDBC 查询结果怎么在 JSP 里渲染

首页通常是一个带分页的线路列表。常见做法是维护一个 PageInfo 对象,包含当前页、每页条数、总记录数和当页数据。先看 DAO 里的分页查询:

public List<Route> findPage(int start, int pageSize) throws SQLException { String sql = "SELECT rid, rname, price, route_intro, rimage, stock " + "FROM t_route WHERE rflag = 1 " + "ORDER BY rdate DESC LIMIT ?, ?"; // DruidUtils.getConnection() 取连接 // ps.setInt(1, start); // ps.setInt(2, pageSize); // 执行查询,封装成 Route 对象放入 List }

SQL 里的LIMIT ?, ?第一个参数是偏移量,第二个是数量。页面传过来的是第几页,要换算成偏移量(currentPage - 1) * pageSize,这个换算容易漏,漏掉以后第二页起数据全对不上。每页 8 条对课设比较合适,太少显得数据稀,太多分页按钮用不上,答辩时不好展示。在 Servlet 里把 PageInfo 塞进 request:

int currentPage = parseInt(req.getParameter("currentPage"), 1); int pageSize = 8; PageInfo<Route> pageInfo = routeService.page(currentPage, pageSize); req.setAttribute("pageInfo", pageInfo); req.getRequestDispatcher("/index.jsp").forward(req, resp);

JSP 页面用 JSTL 循环渲染,不要用<% for %>脚本片段:

<c:forEach items="${pageInfo.list}" var="route"> <div class="route-card"> <h3> <a href="${pageContext.request.contextPath}/route?method=detail&rid=${route.rid}"> ${route.rname} </a> </h3> <p class="price">¥${route.price} 起</p> <p>${route.routeIntro}</p> <p>剩余名额:${route.stock}</p> </div> </c:forEach>

c:forEachitems指向pageInfo.listvar是循环变量名,等价于 Java 里的增强 for。用它而不用 scriptlet,一是 JSP 文件里不出现 Java 代码,二是答辩时能回答出“我用了 JSTL 做页面展示逻辑”,这是 JavaWeb 教学案例里比较受认可的做法。页脚再加一组分页链接,把currentPage传到同一个route?method=list上即可。注意边界判断:第一页不显示“上一页”,最后一页不显示“下一页”,用<c:if>包一下,这种小细节评审老师也会注意到。

4. 订单与收藏:让在线旅游网站的业务真正形成闭环

很多课设项目做到这里就停了:首页能看,线路能点,然后没有然后了。在线旅游网站之所以叫“在线”,核心在预订动作,订单和收藏这两块是拉开差距的地方。

4.1 线路详情与“立即预订”:URL 传参的隐藏坑

列表页的每个线路名都指向/route?method=detail&rid=${route.rid},详情页 Servlet 接收 rid 后查单条线路:

public void detail(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String rid = req.getParameter("rid"); if (rid == null || !rid.matches("\\d+")) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "rid 参数不合法"); return; } Route route = routeService.findById(Integer.parseInt(rid)); if (route == null || route.getRflag() != 1) { resp.sendError(HttpServletResponse.SC_NOT_FOUND, "线路不存在或已下架"); return; } req.setAttribute("route", route); req.getRequestDispatcher("/route_detail.jsp").forward(req, resp); }

这段代码真正要防的不是格式错误,而是两类真实情况:列表页拿到的 rid 可能在用户手动改 URL 时被传成负数或非数字字符;一条已下架线路的历史链接还能访问详情页,等于绕过业务规则。所以先做正则校验,再查库判断 rflag 状态。matches("\\d+")只接受纯数字,解析前顺手做掉,能少写一堆异常分支。findById返回 null 的情况也要兜住,否则 Integer.parseInt 成功但查不到记录时,页面会直接抛 NullPointerException。详情页的“立即预订”按钮指向/order?method=add&rid=${route.rid},注意这个请求必须带 rid。我见过有人把 rid 存在 Session 里,用户开两个标签页后下单,结果下了另一条线路的单,这种症状特别难查,所以 URL 参数该传就传,别依赖全局状态。

4.2 下单与事务:为什么订单入一半是课设里高频翻车现场

下单动作至少包含两个写操作:插入订单记录、扣减线路库存。如果只调用两条 SQL 却不在同一个事务里,插入订单成功后扣库存失败,页面会提示“下单失败”,数据库里却多了一条脏订单,反过来也一样。这种问题靠代码 review 很难发现,一定要在 service 层开事务。手动事务的代码骨架如下:

public boolean addOrder(Order order, int rid) { Connection conn = null; try { conn = DruidUtils.getConnection(); conn.setAutoCommit(false); OrderDao orderDao = new OrderDao(); RouteDao routeDao = new RouteDao(); orderDao.insert(conn, order); routeDao.decreaseStock(conn, rid, order.getPeople()); conn.commit(); return true; } catch (Exception e) { if (conn != null) conn.rollback(); throw new RuntimeException("下单失败,订单与库存已回滚", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) { } } } }

这段代码的细节集中在三处。第一,setAutoCommit(false)必须在拿到连接后立刻执行,它之后的 insert 和 update 都处于同一个事务里;第二,catchrollback()保证两条 SQL 要么都成功,要么都不生效;第三,finally里恢复setAutoCommit(true)是关键习惯,因为连接是从 Druid 连接池借的,归还前不恢复自动提交,这个连接被后续请求借走时会沿用 false 状态,导致后面的操作全部不提交,这种玄学问题排查起来极其消耗时间。参数order.getPeople()对应 t_order 里的出游人数,它同时用于扣减库存,两件事本质是同一个业务动作。有的课设会在 Order 对象里带上totalPrice,订单金额要在 service 层重新用price * people计算并覆盖前端传来的值,避免用户篡改表单金额,这一步叫服务端重算金额,建议在文档说明里强调,老师非常爱问。

4.3 收藏功能与联合主键:防重复收藏靠数据库不靠前端按钮

收藏表的核心设计是联合主键(uid, rid),它保证一个用户对一条线路至多一条收藏记录,重复收藏会被数据库直接拒绝。很多同学在收藏按钮上用前端disabled防二次点击,但浏览器刷新、双开页面、网络重试都会绕过按钮状态,所以最终防线要落在数据库约束上。收藏的 DAO 可以用一条 upsert 语句:

public void saveFavorite(Favorite fav) throws SQLException { String sql = "INSERT INTO t_favorite(uid, rid, fav_time) VALUES (?, ?, NOW()) " + "ON DUPLICATE KEY UPDATE fav_time = NOW()"; // 用 PreparedStatement 执行 }

INSERT ... ON DUPLICATE KEY UPDATE是 MySQL 的写法,逻辑是:如果插入时碰到联合主键冲突,就改为更新 fav_time。这样收藏按钮永远是幂等的,点十次和点一次在数据上只有时间戳的差别,不会产生重复行。收藏状态查询也走同一个表:查t_favorite里是否存在uid + rid的记录,存在渲染“已收藏”,不存在渲染“收藏”。由于有联合主键兜底,按钮文案和真实状态不一致的情况会少很多。收藏或下单之后,要立即做一次sendRedirect,把 URL 重定向回列表页或详情页,不要在 POST 之后直接forward到成功页。原因是刷新页面时浏览器会重新提交最后一次请求,如果最后一个动作是 forward,刷新就等于再执行一次收藏或下单;重定向之后浏览器最后一次请求变成 GET,刷新只会重新拉页面,不会再写数据。这个模式叫 PRG,答辩时可以提这个词,和 3.2 登录成功用sendRedirect是同一个理由。

5. 避坑指南:JavaWeb 课设里最常见的五个翻车现场

这一章把排查过程中遇到最多的五个问题写出来,每一条都按“现象 → 原因 → 解决”展开,最后一条尤其隐蔽。

5.1 启动就报 ClassNotFoundException: javax.servlet.*

现象:Tomcat 启动直接报错,或者浏览器打开首页出现 500,控制台提示ClassNotFoundException: javax.servlet.http.HttpServlet,但代码里 import 明明没错,pom 里也配了依赖。

原因:版本换代问题。Tomcat 10 起,Servlet API 的包名从javax.servlet迁移到jakarta.servlet。如果你的项目基于旧代码写,用的是javax.servlet-api依赖,跑在 Tomcat 10 上必然找不到类。

解决:两个方向选一个。一是换 Tomcat 9,这是课程设计最省事的解法,代码和依赖都不动;二是把所有import javax.servlet改成import jakarta.servlet,同时把 pom 里的 Servlet API 依赖换成jakarta.servlet-api5.x。我一般建议先看代码里 import 了多少处,超过十处就换 Tomcat,别跟包名较劲。

5.2 表单提交后中文全部变问号

现象:注册页面昵称填“小明”,存进数据库变成“??”,但查询页面显示却正常。

原因:三个环节至少有一个字符集不一致。MySQL 表字符集、JDBC 连接串里的 characterEncoding 参数、Servlet 请求编码,任一个不是 UTF-8 就会出现这种问题,最常见的是连接串没加 characterEncoding。

解决:三步全部设成 UTF-8。第一步建库建表用utf8mb4;第二步 JDBC 连接串写全:jdbc:mysql://localhost:3306/travel?characterEncoding=utf8&useUnicode=true&serverTimezone=Asia/Shanghai;第三步加一个编码 Filter,逻辑可以参考 3.2 里的登录拦截 Filter,去掉登录判断即可。注意时机:Filter 必须在getParameter之前设置request.setCharacterEncoding("UTF-8"),否则参数已经被按默认 ISO-8859-1 解码了,再设置就不起作用。同理会话里的中文乱码,大部分也是因为响应没设resp.setContentType("text/html;charset=UTF-8")

5.3 MySQL 8 连接报 Public Key Retrieval is not allowed

现象:项目换到 MySQL 8 环境后,第一次获取连接就抛SQLNonTransientConnectionException: Public Key Retrieval is not allowed,页面直接 500。本地 5.7 跑得好好的,迁移到机房就炸。

原因:MySQL 8 默认的认证插件是caching_sha2_password,客户端第一次连接需要向服务端请求公钥来加密密码,但 JDBC 驱动出于安全考量默认不允许自动获取公钥,连接串里没授权就在握手阶段被拒绝。

解决:在连接串末尾追加allowPublicKeyRetrieval=true&useSSL=falseuseSSL=false是为了避免本地开发环境因为证书问题又冒出新的 SSL 握手报错。注意这两个参数是 JDBC URL 上的,不是 MySQL 配置项,改完连接串必须重启项目才生效。如果机房 MySQL 是 8 以上但驱动太老,也可能触发这个报错,先把 mysql-connector-java 升到 8.0.x 再排查。

5.4 订单时间在 JSP 页面显示成一串数字

现象:订单列表页上order.orderTime输出了一串类似2026-04-01 13:20:33.0的长字符串,格式难看,和文档说明里的截图对不上。

原因:java.sql.TimestamptoString()输出包含毫秒,EL 表达式${order.orderTime}直接调用了 toString,所以显示不是预期格式。这不算代码错误,但答辩时看起来很不专业。

解决:在 JSP 顶部引入 JSTL 的 fmt 标签库,然后用 formatDate 格式化:

<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <fmt:formatDate value="${order.orderTime}" pattern="yyyy-MM-dd HH:mm"/>

fmt:formatDate要求 value 是java.util.Date类型,而java.sql.Timestamp继承自java.util.Date,可以直接传。pattern 里HH是 24 小时制,hh是 12 小时制,这个字母大小写经常被拼错,拼错后会出现同一张表里上下午时间错乱的问题。线路的上架日期同样建议统一用这个标签处理。

5.5 刷新页面订单多了一条

现象:用户点“提交订单”后成功跳到订单列表页,顺手刷一下,订单列表里多了一模一样的记录。收藏也是同样的问题,刷新后收藏时间不停被更新。

原因:表单提交是 POST 请求,提交成功后如果forward到成功页,浏览器地址栏仍是 POST 地址,刷新就会重发 POST,再执行一次下单或收藏逻辑。前端按钮置灰只能挡新手,挡不住浏览器刷新。

解决:下单、收藏成功后一律sendRedirect,也就是 PRG 模式。让最后一次响应变成 302 + GET,刷新只会重新请求列表页,不会重放写操作。想做得更严格,可以在页面生成一次性 token 存进 Session,提交时校验后立即失效,双保险。对课设来说 PRG 足够,token 可以在文档说明里作为扩展点提一句。

6. 验收前的自查:让项目在老师电脑上也能跑起来

再好的代码,只要不能在教室电脑上跑一遍,前面全白搭。我给自己做的项目预留半小时验收时间,按下面这张清单过一遍。

检查项具体操作通过标准
数据库导出Navicat 或 MySQL Workbench 转储 SQL 文件导入后所有表和数据都在,字符集为 utf8mb4
运行环境记录 JDK 版本、Tomcat 版本与项目 pom 依赖匹配,避免 javax/jakarta 冲突
初始账号用 admin / 123456 登录登录后右上角正常显示昵称
演示路径首页 → 线路详情 → 收藏 → 下单 → 订单列表五个页面全部可点通,不出现 404
端口检查8080 被占用时用命令处理服务能正常启动

Tomcat 端口被占用是最常见的环境问题,直接给一条排查命令:

netstat -ano | findstr 8080

看到 PID 后用taskkill /PID 对应PID /F结束进程。注意先把占用端口的程序列表看清楚再动手,机房电脑上经常有已打开的旧 Tomcat 在后台占着 8080,不要误杀其他同学的进程。启动后可以用一个最基础的冒烟验证代替手工点页面:

curl -I http://localhost:8080/travel/

返回HTTP/1.1 200说明 Tomcat 部署没问题;如果 404,先检查 Application context 是否写成/travel,再看部署里的 Artifact 是不是 exploded 模式,这两处是 IDEA 运行 JavaWeb 项目配置里最容易出岔子的点。文档说明这一块,我习惯把环境版本、初始账号、从建库到启动的三步命令放在最前面,后面才是需求分析、数据库设计、核心功能截图。答辩老师通常不会通读全文,而是先照文档跑一遍,再快速翻几个关键章节。我自己踩过最亏的一次,是代码完全没问题,但文档里的数据库密码写成自己电脑上的,老师照着跑一直报连接失败。从那以后,数据库连接配置单独放一个db.properties,文档里只写占位符并加一行注释提醒修改,这个习惯后来一直保留。希望帮到你。

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

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

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

立即咨询