☰
Java旅游信息网站设计与实现:从Servlet到部署的完整方案
2026/10/6 1:36:19 网站建设 项目流程

简介:这是基于Java的旅游信息网站设计与实现文档,面向计算机专业毕业设计、课程设计及Web开发学习者,可用于参考旅游类网站系统的完整设计方案。文档采用MySQL数据库、Java后端技术,在Tomcat服务器与Eclipse平台上开发,围绕用户前台和后台管理两大模块展开:前台涵盖景点展示、论坛交流、新闻资讯、购物车、客服等功能,后台包括用户管理、旅游景点管理、交流论坛管理、订单管理、系统管理等模块。系统采用权限认证方式保障信息安全,同时兼顾代码可读性、扩展性与页面简洁性。压缩包内为单个docx文件,共1个文件,大小约6.01MB,目前已有73人学习下载。文档包含中英文摘要、目录及详细章节,对需求分析、系统设计、数据库设计及功能实现均有清晰阐述,可作为毕业设计文档的写作模板,也能为开发同类信息网站提供参考思路。

1. 基于Java的旅游信息网站:这标题背后是一套完整可落地的Java Web全栈方案

“基于java旅游信息网站设计与实现.docx”这个标题,在计算机类毕业设计和课程设计题库里属于常青树,每年都有一批学生选它。它本质上是一个典型的Java Web全栈项目:前台面向游客和注册用户,展示景点、旅游线路、酒店信息,支持搜索、收藏、预订下单;后台面向管理员,维护景点和酒店数据、处理订单、管理用户。适合正在挑毕设题目的人,也适合想用一个完整CRUD项目把Servlet、JSP、JDBC、MySQL这些知识点串起来练手的开发者。这个方向的好处是需求明确、参照多、答辩容易讲,坏处是细节坑不少——中文乱码、金额精度、连接没关导致假死,每一条都能让交付时间多出两三天。下面按从选型到落地的顺序,把整个方案讲清楚。

2. 系统模块与技术栈选型:先拆需求再选框架,别一上来就建表写Servlet

做这类网站最忌讳的是打开IDE就建表、写Servlet,写到一半发现管理端功能没地方放。我一般会先花半天把业务模块拆清楚,再决定用什么技术栈。模块拆得清楚,后面无论是写代码还是写论文,都只需要往里填内容。

2.1 系统功能拆解:前台用户端和后台管理端各管什么

旅游信息网站的业务可以拆成两个端,它们共享同一套数据库和公共工具类,只是代码路径不同。用户端包含注册登录、景点浏览、景点搜索(按城市或名称)、旅游线路展示、酒店信息展示、在线预订下单、个人中心查看订单、收藏景点、发表评论。管理端包含景点信息增删改查、景点上下架、线路管理、酒店管理、订单状态处理(待支付、已支付、已取消)、用户列表管理。

模块 | 用户端功能 | 管理端功能 景点模块 | 景点列表、详情、按城市搜索 | 景点CRUD、上架/下架 线路模块 | 线路列表、线路详情 | 线路CRUD 酒店模块 | 酒店列表、酒店详情 | 酒店CRUD 订单模块 | 创建订单、查看订单、取消订单 | 订单状态修改 用户模块 | 注册、登录、个人信息 | 用户列表、启用/禁用 评论模块 | 发表评论、查看评论 | 删除违规评论

这个拆分不是拍脑袋。旅游网站的订单和普通电商不同,订单里涉及景点门票、线路团期、酒店房型三个维度,如果一开始不把订单归属到“景点门票”这种单一业务上,后面事务处理和价格计算会变得很难收场。所以第一版做减法,订单只做景点门票预订,线路和酒店先只做信息展示,这是大多数毕设项目的合理边界。

2.2 JSP+Servlet+JDBC还是Spring Boot:答辩友好度决定选型

选型是第一个容易翻车的决策点。我的建议是:如果这是课程设计或毕业设计,优先选传统的 JSP + Servlet + JDBC + MySQL + Tomcat 组合。原因不是这套技术新,而是答辩时老师问“一个请求从浏览器到数据库怎么走”,你可以完整画出链路:浏览器发请求 → Tomcat 解析 → 找到对应 Servlet → Service 层处理逻辑 → DAO 层拼接 SQL → JDBC 连接数据库 → 结果逐层返回渲染到 JSP。每一个环节都能落到具体代码。

Spring Boot 当然开发效率高,自动配置把数据源、事务、JSON序列化全包了,但这也意味着答辩时老师一句“自动配置的原理是什么”“Spring Boot 怎么帮你省掉了 web.xml”,回答不上来就是明显减分。这不是说 Spring Boot 不能选,而是说选它之前得先把传统 Servlet 的原理吃透。我见过不少用 Spring Boot 做得挺顺利的同学,答辩时被追问底层机制,最后只能靠“框架内部处理的”这种话撑场,场面很尴尬。

如果导师明确要求 Spring Boot + MyBatis,那就用,但本文的核心表结构、字段设计和业务逻辑依然适用。Spring Boot 只是替换了最外层的请求接入方式,数据库设计思路和业务代码组织方式是通用的。另外说一句,无论是哪种选型,建议把 Java 基础里的数据类型、集合、异常处理先复习一遍,这些在写 DAO 层和 Service 层时天天要用。

2.3 开发环境与Maven工程骨架:版本兼容是最大的隐藏坑

环境搭配有一个经典组合:JDK 8 + Tomcat 8.5 + MySQL 5.7 + Maven 3.6。这组合兼容性最好,网上的参考代码几乎都能直接跑。别轻易尝试 JDK 17 + Tomcat 10,因为 Tomcat 10 把javax.servlet包换成了jakarta.servlet,老教程里的import javax.servlet.http.HttpServlet全部编译报错,排查起来非常费时间。

环境变量配置是老生常谈但必须做对:JAVA_HOME指向 JDK 安装目录,PATH里追加%JAVA_HOME%\bin,MAVEN_HOME指向 Maven 解压目录,PATH里追加%MAVEN_HOME%\bin。配好后在命令行执行java -version和mvn -v验证,看到版本信息再继续。

Maven 工程目录建议按下面这个结构建,它把实体、数据库操作、业务逻辑、请求入口分开了:

travel-website/ ├── pom.xml ├── src/main/java │ ├── com/example/entity # User, Scenic, Order 等实体类 │ ├── com/example/dao # 数据访问层,只负责SQL │ ├── com/example/service # 业务逻辑层,处理判断和事务 │ ├── com/example/servlet # 控制器层,接收请求返回JSON或跳转页面 │ └── com/example/util # MD5加密、DBUtil连接工具 ├── src/main/resources │ ├── db.properties # 数据库连接配置 │ └── jdbc.properties └── src/main/webapp ├── WEB-INF/web.xml # Servlet注册、Filter配置 ├── index.jsp # 首页,景点列表入口 ├── css/ # 静态样式 ├── js/ # 前端交互和AJAX请求 └── pages/ # 详情页、登录页、后台管理页

在pom.xml里只需要依赖javax.servlet-api、mysql-connector-java、jstl这几个核心库。不要一上来就引一堆框架依赖,项目逻辑简单时,依赖越少,出问题时的排查范围越小。这个工程骨架把代码分层固定下来,后面写登录、分页、下单都是在对应包下加类,不会出现一个 Servlet 里写几百行 JDBC 代码的情况。

3. 数据库设计与建表SQL:8张表怎么撑起整个旅游网站

数据库设计是这类项目的地基。表结构设计合理,后面写 DAO 和 Service 会非常顺畅;设计不合理,光是订单金额和景点库存的关联就能让你改到怀疑人生。这个网站的规模不需要过度设计,但也不能只建两三张表凑数,8张表是覆盖前后台功能的最小完整集合。

3.1 表结构总览:从用户到订单的完整数据链路

核心表有 8 张:用户表user、管理员表admin、景点表scenic、旅游线路表travel_line、酒店表hotel、订单表orders、评论表comment、收藏表favorite。

它们之间的关系是这样的:用户和订单是一对多,一个用户可以有多个订单;用户和景点是多对多,通过收藏表关联;用户和评论是一对多;景点和评论是一对多。订单表通过user_id关联用户、scenic_id关联景点,这是整个系统里最重要的一条关联链路。如果后续要加酒店预订,再建一张hotel_order表,不要让订单表既存门票又存酒店信息,否则统计和状态流转会越来越混乱。

admin表单独存管理员账号,不跟普通用户混在一个表里,是为了后台登录时逻辑简单:普通用户登录查user表,管理员登录查admin表,两张表的密码加密方式可以一致,但 Session 里存的用户类型不同,后台的 Filter 就能根据这个类型做权限判断。

3.2 核心建表SQL:用户表、景点表、订单表一次建好

下面这三张表的建表语句是整套系统的核心,建议直接复制后用,字段和注释都按毕设标准写了:

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(64) NOT NULL COMMENT '加盐MD5后的密文', `email` VARCHAR(100) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像图片路径', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `scenic` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `city` VARCHAR(50) NOT NULL COMMENT '所在城市,用于按城市搜索', `description` TEXT COMMENT '景点介绍', `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '门票价格', `stock` INT NOT NULL DEFAULT 100 COMMENT '每日可售库存', `open_time` VARCHAR(50) DEFAULT '08:00-17:00', `image` VARCHAR(255) DEFAULT NULL COMMENT '封面图片相对路径', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_city` (`city`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,前台展示用', `user_id` INT NOT NULL, `scenic_id` INT NOT NULL, `ticket_count` INT NOT NULL DEFAULT 1 COMMENT '购买张数', `total_price` DECIMAL(10,2) NOT NULL COMMENT '总价 = 单价 * 张数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这三张表的设计有几个关键点。订单号order_no用唯一索引,业务上展示给用户看的订单号不能依赖自增主键,因为id会被用在关联查询里,直接暴露给用户不合适。scenic表加了stock字段,这是后面写下单事务时要扣减的库存,不加这个字段,订单和库存就完全脱节,两个人同时买最后一张票时就会出现超卖。价格字段全部用DECIMAL(10,2),这是整个项目里最重要的一个选型决定,后面详细说。

3.3 字段设计细节:金额、时间、图片的存法决定了你要不要返工

字段设计里最容易返工的是三类:金额、时间、图片。金额必须用DECIMAL,不能用double或float。double在二进制里无法精确表示 0.1,算出来的订单总价会出现 49.999999 这种值,往数据库里一存,再查出来显示,用户看到的就是一个带着一串 9 的价格。用DECIMAL(10,2)配合BigDecimal,金额问题从源头就消失了。

时间字段用DATETIME,不要用VARCHAR存字符串。DATETIME配合 MySQL 的DEFAULT CURRENT_TIMESTAMP,插入时不用手动填时间,查询时还能用 MySQL 的日期函数做按天统计。图片字段存相对路径,比如/upload/scenic/1.jpg,不存二进制内容也不要存完整 URL,这样项目换服务器时只需要迁移 upload 目录,图片显示问题不会变成硬编码的 IP 地址残留。

3.4 用户密码存储:加盐MD5的工具方法直接抄

用户密码不能明文存数据库,这是安全底线,后台管理员能看到user表,明文密码等于把用户账号拱手送人。常见的做法是加盐 MD5,核心代码如下:

public class MD5Util { private static final String SALT = "travel2024"; public static String md5WithSalt(String input) { String text = input + SALT; try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(text.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5加密失败", e); } } }

这段代码里SALT是固定的盐值,实际项目里可以用用户注册时间或随机数做盐,但毕设项目固定盐值已经足够,重点是密码不以明文形式出现在数据库里。登录验证时,把用户输入的密码加上同样的盐做 MD5,再和数据库里存的值比对,而不是把数据库的密文解密后比对。这里顺带提醒一句,上面是简化方案,生产环境强烈建议用 BCrypt,但毕设答辩场景下加盐 MD5 已经能应付提问了。

4. 后端核心接口实现:登录、分页、下单三个Servlet把业务串起来

数据库建好后,接下来就是把业务流程跑通。这个项目的后端代码量不算大,核心就是登录、景点列表分页、下单三个接口。把这三个接口写明白,其他模块照着抄就行了。这里我用 Servlet 的方式来实现,代码结构是经典的三层架构。

4.1 分层架构与请求链路:Servlet、Service、DAO各司其职

分层的目的是让每段代码只干一件事。Servlet 接收 HTTP 请求、解析参数、返回 JSON 或跳转页面,不写 SQL;Service 层处理业务判断,比如登录时校验密码、下单时判断库存够不够,不碰HttpServletRequest;DAO 层只做数据库操作,接收参数拼 SQL 返回结果集,不关心请求从哪来。

一个请求的实际链路是:浏览器发请求 → 找web.xml里注册的 Servlet → Servlet 里调 Service 方法 → Service 里调 DAO 方法 → DAO 里用 JDBC 查 MySQL → 结果一层层返回,Servlet 把它转成 JSON 输出给前端。这个链路在答辩时一定要能说清楚,它是整个项目的主心骨。

4.2 登录接口:从Request参数到Session的全过程

登录是所有业务的前提,用户没登录就不能下单,所以这个接口必须写得干净。下面是LoginServlet的核心代码:

@WebServlet("/user/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); String username = req.getParameter("username"); String password = MD5Util.md5WithSalt(req.getParameter("password")); User user = userService.login(username, password); PrintWriter out = resp.getWriter(); if (user != null) { req.getSession().setAttribute("loginUser", user); out.write("{\"success\":true,\"msg\":\"登录成功\"}"); } else { out.write("{\"success\":false,\"msg\":\"用户名或密码错误\"}"); } } }

这段代码里有几个细节是新手容易漏的。第一行setCharacterEncoding("UTF-8")必须放在读取任何参数之前,否则前端传过来的中文用户名会乱码。密码在前端传过来时是明文,后端先做加盐 MD5 再拿去查库,这样数据库里永远只存密文。登录成功后在 Session 里存了loginUser对象,后续的所有需要登录的接口都会通过这个 Session 判断身份。

对应的UserDao里查询逻辑是:

public User findByUsernameAndPassword(String username, String password) { String sql = "SELECT id, username, email, phone, avatar, create_time FROM user WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setEmail(rs.getString("email")); u.setPhone(rs.getString("phone")); return u; } } } catch (SQLException e) { e.printStackTrace(); } return null; }

注意这里SELECT不能查password字段,即使查了也不要set到User对象里,防止对象在 Session 中保存时把密文暴露给前端页面。PreparedStatement用?占位符传参而不是拼字符串,既避免 SQL 注入,也避免引号转义的麻烦。

4.3 景点列表分页接口:LIMIT与参数绑定的边界处理

景点列表是首页和搜索页的核心接口,它涉及分页、按城市筛选、总记录数统计,是一个典型的数据查询场景。分页参数由前端传来:page表示第几页,size表示每页几条。Servlet 收到参数后要计算出偏移量offset = (page - 1) * size,然后传给 DAO 做LIMIT offset, size查询:

@WebServlet("/scenic/list") public class ScenicListServlet extends HttpServlet { private ScenicService scenicService = new ScenicService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); int page = parseInt(req.getParameter("page"), 1); int size = parseInt(req.getParameter("size"), 8); String city = req.getParameter("city"); int offset = (page - 1) * size; List<Scenic> list = scenicService.queryPage(city, offset, size); int total = scenicService.count(city); int totalPage = (int) Math.ceil(total * 1.0 / size); Map<String, Object> data = new HashMap<>(); data.put("list", list); data.put("total", total); data.put("totalPage", totalPage); data.put("page", page); data.put("size", size); out.write(new Gson().toJson(data)); } private int parseInt(String param, int defaultValue) { if (param == null || param.isEmpty()) { return defaultValue; } try { return Integer.parseInt(param); } catch (NumberFormatException e) { return defaultValue; } } }

parseInt这个工具方法是必要的,前端传page=abc时直接Integer.parseInt会抛异常让整个接口 500。参数异常时给默认值是最稳妥的处理方式。DAO 层查询时只有一条核心 SQL 需要看清楚:

SELECT id, name, city, price, image, open_time, description FROM scenic WHERE status = 1 AND (? = '' OR city = ?) ORDER BY create_time DESC LIMIT ?, ?

这里? = '' OR city = ?的写法是为了处理城市筛选为空的情况:前端没传城市参数时,city变量是空字符串,条件恒为真,就查全部;传了城市参数就只查该城市的景点。LIMIT的两个问号绑定时一个用setInt绑offset,一个绑size,顺序不能反。

4.4 下单事务:一次Connection里完成扣库存与建订单

下单是整个项目里唯一涉及事务的接口。一个完整的下单动作要做两件事:扣减景点库存、插入订单记录。这两件事必须在同一个数据库事务里完成,否则就会出现扣了库存但订单没生成,或者订单建了但库存没扣的脏数据。核心代码如下:

public boolean createOrder(Order order) { String deductSql = "UPDATE scenic SET stock = stock - ? WHERE id = ? AND stock >= ?"; String insertSql = "INSERT INTO orders(order_no, user_id, scenic_id, ticket_count, total_price, status) VALUES (?,?,?,?,?,?)"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); PreparedStatement ps1 = conn.prepareStatement(deductSql); ps1.setInt(1, order.getTicketCount()); ps1.setInt(2, order.getScenicId()); ps1.setInt(3, order.getTicketCount()); int rows = ps1.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } order.setOrderNo(generateOrderNo()); PreparedStatement ps2 = conn.prepareStatement(insertSql); ps2.setString(1, order.getOrderNo()); ps2.setInt(2, order.getUserId()); ps2.setInt(3, order.getScenicId()); ps2.setInt(4, order.getTicketCount()); ps2.setBigDecimal(5, order.getTotalPrice()); ps2.setInt(6, 0); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

这段代码的核心在UPDATE scenic SET stock = stock - ? WHERE id = ? AND stock >= ?。AND stock >= ?是一个乐观锁判断:如果库存不够,executeUpdate返回 0 行,说明更新失败,直接在事务里回滚,从根上防止了超卖。setAutoCommit(false)之后所有 SQL 都不会自动提交,只有执行到conn.commit()才真正生效,中途任何异常都走rollback()。

这里有一个血泪经验:finally块里conn.close()之前先setAutoCommit(true),是把连接重置成默认状态再归还给连接池。如果不重置,这个连接下次被复用时会继续保持事务状态,导致后续所有操作都不自动提交,数据迟迟不落库,排查起来极其折磨人。

5. 联调、部署与避坑:五个让新手卡住的高频问题

后端接口写完,前端页面加载数据,最后部署到 Tomcat 跑起来,这一步是整个项目从“代码能编译”到“能演示”的关键环节。联调时最常见的问题不是逻辑写错,而是前端和后端对数据格式的理解不一致,或者环境配置的细节没对齐。

5.1 前端AJAX与Servlet的数据契约:统一JSON返回格式

前端和后端之间必须约定一个统一的返回格式,我习惯用{code: 0, msg: "success", data: {...}}这种结构。code为 0 表示成功,非 0 表示业务失败,比如未登录可以定义成code: 401。这样前端只需要判断code,不需要关心每个接口的success字段是叫ok还是叫isSuccess。

前端用fetch请求后端接口的典型代码如下:

async function loadScenicList(page, size, city) { const url = `/travel-web/scenic/list?page=${page}&size=${size}&city=${encodeURIComponent(city || '')}`; const resp = await fetch(url); const result = await resp.json(); if (result.code === 0) { renderScenicCards(result.data.list); } else { console.error('加载景点列表失败', result.msg); } }

encodeURIComponent处理城市参数是必须的,因为用户输入的城市名可能包含中文,URL 里直接拼中文在某些浏览器和服务器组合下会乱码。fetch默认不带 Cookie,如果后端接口依赖 Session 判断登录状态,需要在fetch里加credentials: 'same-origin',否则即使前端已经登录,后端拿到的 Session 仍然是空的。

5.2 部署到Tomcat:Maven打包与启动参数

项目开发完成后打包部署是固定的流程。在 IDEA 里执行mvn clean package,生成target/travel-website.war,把这个 war 包复制到 Tomcat 的webapps目录,然后启动 Tomcat 即可。也可以用 IDEA 的 Tomcat 集成插件直接部署,但把 war 包扔进webapps的方式更适合现场演示,因为换一台电脑也能快速部署。

启动 Tomcat 时建议修改bin/catalina.bat(Windows)或catalina.sh(Linux),加上JAVA_OPTS="-Dfile.encoding=UTF-8"。这一步看着是玄学,实际是系统级编码问题。如果不指定,Tomcat 在 Windows 上可能用 GBK 解析 JSP,页面上的中文直接变乱码。

5.3 避坑清单:五个高频问题与排查路径

第一个坑:Tomcat 启动失败,报Address already in use: 8080。现象是双击startup.bat后立刻闪退,命令行显示端口被占用。原因多数是之前启动的 Tomcat 没关干净,或者其他程序占用了 8080 端口。解决方法是先在命令行执行netstat -ano | findstr 8080看谁占用了端口,然后taskkill /PID <pid> /F结束掉它,或者修改 Tomcat 的server.xml把端口改成 8081。这里建议优先杀掉占用进程,因为改端口后所有前端请求路径里的端口都要跟着改,容易漏。

第二个坑:页面和数据库里的中文全部变成问号。现象比较经典:前端页面上景点名字显示成???,数据库里查出来也是???。原因是 JSP 文件编码、Servlet 读取参数编码、数据库连接串编码三处没有统一。解决方法是三处统一用 UTF-8:JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" %>,Servlet 里在读取参数前执行req.setCharacterEncoding("UTF-8"),JDBC 连接串写成jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai。同时 MySQL 建表时指定CHARSET=utf8mb4,三处对齐后问题自然消失。

第三个坑:订单总价出现 49.999999。现象是在页面上看到订单金额是 50 元整,但订单列表里显示 49.999999。原因是在 Java 代码里用了double做乘法,8.5 * 3在二进制里不是精确的 25.5。解决方法是金额计算全链路用BigDecimal,从 Servlet 接收参数到 Service 计算总价,再到 DAO 写入数据库,一律BigDecimal,数据库字段用DECIMAL(10,2)。这里特别提醒,BigDecimal构造时要用BigDecimal.valueOf(price)或new BigDecimal(String.valueOf(price)),不要直接new BigDecimal(8.5),否则精度问题绕了一圈又回来了。

第四个坑:网站运行半天后所有接口超时,Tomcat 假死。现象是刚启动时一切正常,过几小时后刷新页面一直转圈,后台日志报Connection is not available, request timed out。原因是 DAO 层查询数据库时,获取了Connection但没有在finally块里关闭,连接池的连接被耗尽。解决方法是所有 DAO 方法里用try-with-resources语句:

try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { // 处理结果集 } catch (SQLException e) { e.printStackTrace(); }

try-with-resources会自动关闭ResultSet、PreparedStatement、Connection,从语法层面杜绝连接泄漏。如果项目里大量用了手写finally关闭的老代码,可以全局搜索conn.close(),数一数有多少个自己关闭的分支,漏掉的都要补上。

第五个坑:JSP 页面里的${name}原样显示,没有解析成变量值。现象是页面上直接输出${user.username}字符串。原因是 web.xml 里的 Servlet 版本声明太低,或者 JSP 的isELIgnored被设成了true。解决方法是把web.xml的根元素声明改成 Servlet 4.0 标准的版本头,或者在这个 JSP 页面的指令里加<%@ page isELIgnored="false" %>。这个坑在传统 JSP 项目里特别常见,排查顺序是先看页面指令,再看 web.xml 版本。

6. 进阶技巧:写一个登录拦截器,顺带把未登录访问一网打尽

做旅游信息网站这类项目,最后一定要加一个登录拦截器。它解决的是个很实际的问题:用户没登录就直接访问订单页面、收藏接口、个人中心。不加拦截器,每写一个需要登录的接口都要手写一遍 Session 判断,代码重复不说,漏写一个就是安全隐患。

拦截器用 Filter 实现,一次配置,所有需要登录的路径都生效。核心代码如下:

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

注意这里用了request.getSession(false),传false的意思是如果没有 Session 就返回 null,而不是创建一个新的空 Session。有些新手习惯写request.getSession(),那样的话未登录用户第一次访问也会新建一个空 Session,虽然不影响拦截结果,但它会让每次请求都多创建一个无用对象,它也是内存占用问题的起源之一。这段代码在@WebFilter上写死拦截/user/*路径,以后新增的订单、收藏接口只要放在这个路径下,就自动被保护了。

写完拦截器后建议做一次完整的验证:退出登录状态,直接访问http://localhost:8080/travel-website/user/order/list,看是否被重定向到登录页;登录后再访问,看是否能正常返回数据。这一步验证不需要后端调试工具,打开浏览器无痕窗口就能完成。想更严谨一点就用命令行验证,curl http://localhost:8080/travel-website/user/order/list看返回的 HTTP 状态码是不是 302,加-b cookies.txt带上登录后的 Cookie 再看是不是 200,这个流程能确认拦截器逻辑真的生效。

我做这类项目时最亏的一次,就是漏了在 DAO 层关连接,演示时前半场还很流畅,后半场突然所有页面打不开,当时第一反应是代码逻辑炸了,排查到最后才发现是连接池被撑爆了。那次之后,凡是涉及Connection的代码,我都用try-with-resources兜底。这个项目的代码量不大,但把登录、分页、下单、拦截器这几条线走通,Java Web 的核心知识基本就过了一遍,剩下的页面美化就是时间问题。希望帮到你。

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

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

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

立即咨询