简介这是一套基于 ServletJsp 实现的酒店客房预订管理系统采用前后台分离设计面向计算机相关专业毕业设计学生以及需要项目实战的 Java 学习者。系统包含完整的用户端与管理端功能用户可注册登录、搜索客房、在线预约、留言并查看预订记录管理员可管理客房分类、客房信息、会员、留言并查询剩余房间与订房信息。项目基于 Tomcat7 与 MySQL5 及以上环境运行支持 Eclipse 或 IDEA 导入代码经过严格调试可直接用于课程设计与毕业设计。资源包共 170 个文件包含 41 个 Java 源码、41 个编译后 class 文件、34 个 JSP 页面、Jar 依赖库、SQL 数据库脚本及相关配置文件整体大小 1.21MB结构清晰便于二次开发和学习参考。已有 340 人学习下载适合需要快速掌握 JSPServlet 前后台交互流程的初学者也适合正在准备毕业设计的同学参考完整项目分层与业务逻辑实现。1. ServletJSP做酒店客房预定为什么这个组合在课程设计与小项目里还没退场如果你的课程设计或毕业设计也卡在“酒店客房预定”这个题上大概率会看到一个熟悉的方向用ServletJSP做一套分前台的顾客下单页面和分后台的管理页面。这个组合常被说老但它确实是上手最快、最容易在两周内跑通“录入房型、前台查房、提交订单、后台确认、退房结算”闭环的方案。它要解决的东西很具体前台面向顾客的登录注册、查房、预订和个人信息展示页面后台面向管理员的客房维护、订单推进和房价房态管理以及背后负责接收请求和跳转的Servlet入口。适合正在学Java Web、需要独立完成一个可演示项目的开发者也适合接小项目时快速交付第一版。2. 先定请求流转Servlet映射、Action分发与Session会话设计标题里的“分前后台”容易让人误以为是前后端分离架构这里先纠正一个概念。这个项目说的前后台是同一个Java Web应用里两套面向不同用户的页面和Servlet入口前台给顾客用后台给管理员用二者共用同一套数据库但URL、权限和页面完全分开。很多课程设计翻车不是因为代码不会写而是从一开始就没把请求入口划清楚最后所有Servlet混在一起改一个功能要动三个文件。2.1 前后台的URL规划先定模块边界再写代码我一般会先画一个URL规划表把每个模块的路径、Servlet和典型操作定下来再动手。下面这个路由表可以按自己项目的命名习惯调整但前后台分离的原则不要动。模块URL前缀核心Servlet典型action前台-用户/userUserServletregister、login、logout、profile前台-预订/bookingBookingServletsearch、submit、orderList后台-登录/adminLoginAdminLoginServletlogin、logout后台-房间/admin/roomRoomAdminServletlist、save、update、delete后台-订单/admin/orderOrderAdminServletlist、confirm、checkin、checkout这个规划里有个容易被忽略的细节后台登录入口不要放在 /admin 前缀下而是单独用 /adminLogin。因为后面要给后台加登录拦截FilterFilter一旦匹配 /admin/就会把自己也拦住形成“未登录无法登录”的死循环。把登录接口放到前缀外面Filter可以直接对 /admin/做统一拦截不用在代码里反复判断路径。2.2 用BaseServlet统一分发少写一半doGet和doPost一个模块一个Servlet还不够如果每个功能都写一个doGet和doPostServlet会膨胀到上百行。常见做法是写一个BaseServlet通过action参数反射调用子类方法。下面是UserServlet的骨架也是整个项目里我最推荐先复制的代码。WebServlet(/user) public class UserServlet extends HttpServlet { private static final SetString ALLOWED_ACTIONS new HashSet(Arrays.asList( register, login, logout, profile )); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { process(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); process(req, resp); } private void process(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (action null || !ALLOWED_ACTIONS.contains(action)) { resp.sendError(404, action不存在或不允许: action); return; } try { Method method getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(404, 未实现action: action); } catch (Exception e) { log(处理action失败: action, e); req.setAttribute(errorMsg, 系统繁忙请稍后再试); req.getRequestDispatcher(/error.jsp).forward(req, resp); } } }先看白名单ALLOWED_ACTIONS。没有这个集合反射就相当于对外打开了任意方法调用别人在URL里传一个内部方法名就可能触发不安全的操作这是反射路由的最大隐患。新增功能时先把方法名加进白名单再写同名方法方法签名固定为(HttpServletRequest, HttpServletResponse)。doPost里第一行就调setCharacterEncoding位置必须在第一次getParameter之前否则中文参数已经按ISO-8859-1解析后面再设置也不会重新解码。getClass().getMethod只找public方法所以子类里的login、register等方法要写成public不能图省事写成private。URL访问形式是 /user?actionlogin后面所有前台账单、个人信息的请求都走这一个入口。2.3 Session会话设计登录、当前用户、超时和Filter拦截前台登录后要把用户身份放进Session之后每个页面要显示当前操作人都从Session里取。这比每次查数据库高效也符合Session的设计用途。登录方法的典型写法如下。protected void login(HttpServletRequest req, HttpServletResponse resp) throws Exception { String username req.getParameter(username); String password req.getParameter(password); Member user memberDao.findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(60 * 60); resp.sendRedirect(req.getContextPath() /booking?actionsearch); }setMaxInactiveInterval的单位是秒60 * 60表示一小时。不设置的话Tomcat默认超时是30分钟顾客选了半天房回来发现会话已失效体验很差。个人信息展示页面直接用EL表达式 ${sessionScope.loginUser.realName} 就能拿到姓名不需要再往Servlet里传一遍。前台所有需要登录的路径用一个Filter统一拦。这里的关键是getSession(false)它不会为未登录用户创建一个新的Session这个区别很隐蔽但很致命。WebFilter(/booking) public class LoginFilter implements Filter { Override 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); } }如果这里写成request.getSession()未登录用户也会被自动分配一个空SessionFilter判断永远通过不了页面会一直重定向甚至死循环。后台的/admin/*再写一个类似Filter只判断session里的admin对象逻辑一样。这套“前缀隔离Filter拦截”的写法能保证前面URL规划表里的路径边界真正落地后面写业务时基本不用再操心权限。3. 房型、客房、会员、订单四张核心表的建表SQL与状态机业务代码再好看表结构设计错了后期就是灾难。酒店客房预定这个题目核心表就四张房型表room_type、房间表room、会员表member、订单表orders。下面这份SQL是常见且可复现的版本字符集统一utf8mb4避免中文表情符号写入报错。3.1 四张核心表的建表SQL与字段含义CREATE TABLE room_type ( id INT AUTO_INCREMENT PRIMARY KEY, type_name VARCHAR(50) NOT NULL COMMENT 房型名称如豪华大床房, base_price DECIMAL(10,2) NOT NULL COMMENT 挂牌价, bed_type VARCHAR(20) COMMENT 床型, area INT COMMENT 面积单位平方米, max_people INT DEFAULT 2 COMMENT 最多入住人数, image_url VARCHAR(200) COMMENT 房型展示图相对路径, description VARCHAR(500) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE room ( id INT AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(10) NOT NULL UNIQUE, type_id INT NOT NULL COMMENT 所属房型, floor INT COMMENT 楼层, status VARCHAR(20) DEFAULT AVAILABLE COMMENT AVAILABLE/RESERVED/OCCUPIED/MAINTENANCE, CONSTRAINT fk_room_type FOREIGN KEY (type_id) REFERENCES room_type(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE member ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 建议存SHA-256加盐摘要, real_name VARCHAR(50), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, member_id INT NOT NULL, room_id INT NOT NULL, room_type_id INT NOT NULL COMMENT 冗余房型ID防止房型删除后订单查不到, price_snapshot DECIMAL(10,2) NOT NULL COMMENT 下单时的价格快照, check_in DATE NOT NULL COMMENT 入住日期, check_out DATE NOT NULL COMMENT 离店日期, nights INT NOT NULL COMMENT 晚数应用层计算, total_price DECIMAL(10,2) NOT NULL, status VARCHAR(20) DEFAULT PENDING, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_member (member_id), KEY idx_room_time (room_id, check_in, check_out), CONSTRAINT fk_order_member FOREIGN KEY (member_id) REFERENCES member(id), CONSTRAINT fk_order_room FOREIGN KEY (room_id) REFERENCES room(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么把房型和房间拆成两张表因为“标准间、大床房、套房”是房型分类而“101、102、103”是具体房间。一个房型对应多个房间价格通常挂在房型上。如果直接把价格写进room表改一次房价要更新几十行而且容易出现同一房型不同价格的数据错乱。orders表里的price_snapshot是价格快照这个字段很重要。顾客下单时把当时的房价存进去之后就算房型涨价历史订单依然按旧价格结算不会被房型表的修改影响。nights晚数在应用层用LocalDate计算不要依赖MySQL 5.7不支持的生成列否则换低版本数据库环境就报错。3.2 房间里到底有没有被订状态字段与订单状态机新手最容易犯的错是房间是否可用靠去订单表里数记录来判断。这样每次查房都要关联订单表而且状态没理清时明明没有订单却显示可订。我的习惯是room表保留一个status字段四种状态AVAILABLE空闲、RESERVED已预订、OCCUPIED已入住、MAINTENANCE维修/打扫。房态更新和订单创建必须放在同一个事务里保证两边同步。订单表自己的状态机按酒店常见流程走PENDING待支付、CONFIRMED已确认、CHECKED_IN已入住、CHECKED_OUT已退房、CANCELLED已取消。后台确认订单后才允许办理入住退房结算后订单结束。如果业务要求“预订即锁房”那PENDING订单也要把房间置为RESERVED如果允许超时未支付自动释放就需要加一个定时任务把过期PENDING订单改成CANCELLED并释放房态。3.3 锁房SQL用带条件的update别先查再改查房和下单最大的并发问题是两个顾客同时看中了同一间房。如果你先select判断房间是否空闲再insert订单再update房态那中间这个时间窗里另一个请求也会通过判断最后两笔订单落到同一间房。正确做法是把“判断空闲”和“修改房态”合并成一条带条件UPDATE用返回的影响行数判断是否抢到。public boolean lockRoom(Connection conn, int roomId, LocalDate checkIn, LocalDate checkOut) throws SQLException { String sql UPDATE room SET statusRESERVED WHERE id? AND statusAVAILABLE AND NOT EXISTS ( SELECT 1 FROM orders o WHERE o.room_id? AND o.status IN (RESERVED,CONFIRMED,CHECKED_IN) AND o.check_in ? AND o.check_out ?); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, roomId); ps.setInt(2, roomId); ps.setDate(3, Date.valueOf(checkIn)); ps.setDate(4, Date.valueOf(checkOut)); return ps.executeUpdate() 1; } }这段SQL里的区间重叠判断是核心。两个入住区间[a,b)和[c,d)交叠的条件是 c b 且 d a。把已有订单的check_in当作c、check_out当作d新订单的check_in和check_out当作a和b条件自然就是 o.check_in 新check_out AND o.check_out 新check_in。画在时间轴上一看就明白。锁房和订单插入必须放在同一个事务里伪代码如下。conn.setAutoCommit(false); boolean locked lockRoom(conn, roomId, checkIn, checkOut); if (!locked) { conn.rollback(); return 该房间已被预订请重新选择; } int orderId orderDao.insert(conn, order); conn.commit();两个并发请求同时执行这条UPDATE时第二个请求会被数据库行锁挡住等它拿到锁时房间status已经变了影响行数是0自然抢不到房。用这种方式做锁既不需要搞复杂的分布式锁也足够应付课程设计和中小型酒店的真实并发量。4. 前台查房与提交订单一条BookingServlet把校验、下单、减库存串起来前台的核心链路就三步选日期查可用房、填信息提交订单、看订单结果。这里最怕的不是业务复杂而是日期校验、防重复提交和事务边界没做好演示时一刷新就出双订单。4.1 查房列表日期校验和SQL查询分开写Search方法负责解析日期参数先校验再查数据不要把日期比较塞进SQL字符串拼接。protected void search(HttpServletRequest req, HttpServletResponse resp) throws Exception { String checkInParam req.getParameter(checkin); String checkOutParam req.getParameter(checkout); if (checkInParam null || checkOutParam null) { req.setAttribute(errorMsg, 入住日期和离店日期不能为空); req.getRequestDispatcher(/index.jsp).forward(req, resp); return; } LocalDate checkIn LocalDate.parse(checkInParam); LocalDate checkOut LocalDate.parse(checkOutParam); if (!checkOut.isAfter(checkIn)) { req.setAttribute(errorMsg, 离店日期必须晚于入住日期); req.getRequestDispatcher(/index.jsp).forward(req, resp); return; } ListRoomVO roomList roomDao.findAvailable(checkIn, checkOut); req.setAttribute(roomList, roomList); req.setAttribute(checkin, checkInParam); req.setAttribute(checkout, checkOutParam); req.getRequestDispatcher(/bookingList.jsp).forward(req, resp); }LocalDate.parse不合法日期会抛DateTimeParseException如果不想看到500页面可以catch后回跳提示这里为了篇幅没展开。checkin和checkout再放回request里是为了在JSP页面上回填用户已经选好的日期同时作为隐藏域带到下一步提交接口。findAvailable的SQL在3.3节基础上做反向查询room状态是AVAILABLE并且该房间不在冲突订单里。SQL写法如下。SELECT r.id, r.room_no, r.floor, rt.type_name, rt.base_price FROM room r JOIN room_type rt ON r.type_id rt.id WHERE r.status AVAILABLE AND r.id NOT IN ( SELECT o.room_id FROM orders o WHERE o.status NOT IN (CANCELLED, CHECKED_OUT) AND o.check_in #{checkout} AND o.check_out #{checkin} )日期参数通过PreparedStatement的?传进去不要直接拼接进SQL字符串。这条SQL查出来的结果就是前台列表页要展示的房源订单状态为CANCELLED和CHECKED_OUT的不再视为冲突它们不影响新订单入住。4.2 提交订单事务、token、PRG三件套提交订单是整套系统里最需要防御的接口既要防止并发抢房也要防止用户手抖点两次。下面代码是submit方法的骨架结合了第3章的锁房事务和防重复提交token。protected void submit(HttpServletRequest req, HttpServletResponse resp) throws Exception { HttpSession session req.getSession(); Member user (Member) session.getAttribute(loginUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } String token (String) session.getAttribute(bookingToken); String submitToken req.getParameter(token); if (token null || submitToken null || !token.equals(submitToken)) { req.setAttribute(errorMsg, 请勿重复提交订单); req.getRequestDispatcher(/bookingList.jsp).forward(req, resp); return; } session.removeAttribute(bookingToken); int roomId Integer.parseInt(req.getParameter(roomId)); LocalDate checkIn LocalDate.parse(req.getParameter(checkin)); LocalDate checkOut LocalDate.parse(req.getParameter(checkout)); Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); boolean locked roomDao.lockRoom(conn, roomId, checkIn, checkOut); if (!locked) { conn.rollback(); req.setAttribute(errorMsg, 手慢了这个房间刚刚被预订); req.getRequestDispatcher(/index.jsp).forward(req, resp); return; } Orders order Orders.create(roomId, checkIn, checkOut); orderDao.insert(conn, order); conn.commit(); resp.sendRedirect(req.getContextPath() /result.jsp?orderNo order.getOrderNo()); } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) conn.setAutoCommit(true); if (conn ! null) conn.close(); } }token的生成放在进入查房页时session.setAttribute(bookingToken, UUID.randomUUID().toString().replace(-,))然后通过隐藏域带进每个预订表单。提交成功后立刻remove第二次合法请求就进不了业务代码直接返回“请勿重复提交”。这是防重复提交的后端兜底前端按钮disabled只是第一道防线。连接池里的conn.close()并不是真的断开数据库而是把连接归还给池子所以finally里要恢复setAutoCommit(true)否则下次拿到这个连接时还是手动提交模式会出现SQL执行了但看不到数据的情况。dataSource建议在应用启动时用连接池初始化成一个静态实例而不是每个请求DriverManager.getConnection。事务里lockRoom和orderDao.insert共用同一个Connection这是事务生效的前提。如果两个方法各自从DataSource拿连接事务就串不到一起锁了房但订单没写进去房间就永久RESERVED了。4.3 JSP只做展示EL和JSTL代替Scriptlet查房页的JSP核心片段如下用c:forEach循环房源卡片所有属性都用EL表达式输出。% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % div classroom-list c:forEach items${roomList} varroom div classroom-card h3${room.typeName} · ${room.roomNo}/h3 p楼层${room.floor} 床型${room.bedType}/p p参考价${room.basePrice}/晚/p form action${pageContext.request.contextPath}/booking?actionsubmit methodpost input typehidden nameroomId value${room.id} input typehidden namecheckin value${checkin} input typehidden namecheckout value${checkout} input typehidden nametoken value${sessionScope.bookingToken} button typesubmit立即预订/button /form /div /c:forEach /div${pageContext.request.contextPath}是每个JSP都该写的路径前缀它等于部署后的应用名。所有CSS、JS、图片、表单action都要套上这个前缀否则部署到Tomcat时资源路径会错乱。用c:forEach遍历出来的roomList是Servlet里封装好的RoomVO属性对应getter方法不要在JSP里写% %脚本片段去强转request里的对象那既难看又破坏了Servlet和JSP的分工。JSTL标签需要把jstl的jar包放进WEB-INF/lib视图层就干净了。5. 后台管理落地客房CRUD、订单状态推进与图片上传后台管理页面通常不追求花哨但功能比前台多房间增删改查、房型管理、订单确认与退房、图片上传。这里最重要的是操作一致性和事务联动比如退房之后房间状态必须跟着变。5.1 后台统一入口一个RoomAdminServlet管房间CRUD房间管理模块走BaseServlet的分发机制list负责分页查询save负责新增delete负责删除。核心方法如下。WebServlet(/admin/room) public class RoomAdminServlet extends BaseServlet { private final RoomRepository roomRepo new RoomRepository(); public void list(HttpServletRequest req, HttpServletResponse resp) throws Exception { int page req.getParameter(page) null ? 1 : Integer.parseInt(req.getParameter(page)); int pageSize 10; int total roomRepo.count(); int totalPages (int) Math.ceil(total * 1.0 / pageSize); req.setAttribute(page, page); req.setAttribute(totalPages, totalPages); req.setAttribute(roomList, roomRepo.findPage(page, pageSize)); req.getRequestDispatcher(/admin/room_list.jsp).forward(req, resp); } public void save(HttpServletRequest req, HttpServletResponse resp) throws Exception { Room room new Room(); room.setRoomNo(req.getParameter(roomNo)); room.setTypeId(Integer.parseInt(req.getParameter(typeId))); room.setFloor(Integer.parseInt(req.getParameter(floor))); roomRepo.insert(room); resp.sendRedirect(req.getContextPath() /admin/room?actionlist); } public void delete(HttpServletRequest req, HttpServletResponse resp) throws Exception { int id Integer.parseInt(req.getParameter(id)); try { roomRepo.deleteById(id); resp.sendRedirect(req.getContextPath() /admin/room?actionlist); } catch (SQLException e) { req.setAttribute(errorMsg, 该房间存在订单记录无法删除); req.getRequestDispatcher(/admin/room_list.jsp).forward(req, resp); } } }save成功后用sendRedirect而不是forward回表单页否则用户刷新浏览器会再次提交同样的表单房间被插入两遍。分页参数page直接取自request到后端再校验是否为合法数字page小于1就按1处理超过总页数就按总页数处理。delete方法里catch SQLException是因为orders表和room表的外键会阻止删除已被订单引用的房间与其让用户看到500不如提示“存在订单记录”。5.2 订单状态推进带旧状态条件的UPDATE与房态联动后台确认订单时不能只改订单状态还要联动房态。下面这段代码是“确认入住”的典型写法。public void confirm(HttpServletRequest req, HttpServletResponse resp) throws Exception { int orderId Integer.parseInt(req.getParameter(id)); Connection conn dataSource.getConnection(); conn.setAutoCommit(false); try { int rows orderRepo.updateStatus(conn, orderId, PENDING, CONFIRMED); if (rows ! 1) { conn.rollback(); req.setAttribute(errorMsg, 订单状态已变化操作被拒绝); req.getRequestDispatcher(/admin/order_list.jsp).forward(req, resp); return; } orderRepo.updateConfirmTime(conn, orderId); conn.commit(); resp.sendRedirect(req.getContextPath() /admin/order?actionlist); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }updateStatus里WHERE条件要带上旧状态比如WHERE statusPENDING这样能防止两个管理员同时处理同一张订单时互相覆盖。如果影响行数不是1说明订单已经不是待确认状态直接回滚并提示。确认入住时房间状态可以保持RESERVED不变真正办理入住时再把它更新成OCCUPIED。退房方法同理先更新订单为CHECKED_OUT再UPDATE room SET statusMAINTENANCE WHERE id? AND statusOCCUPIED。这两步必须在同一个事务里否则订单退了但房间还显示占用后面查房就会漏掉一间可用房。房间保洁完成后再把MAINTENANCE改成AVAILABLE这个“脏房”状态看似多余实际运营时非常有用。5.3 图片上传与显示文件落盘路径和数据库URL分开存后台房型管理一般要上传展示图Servlet 3.0用Part接口就能处理不需要额外引入文件上传组件。MultipartConfig(maxFileSize 2 * 1024 * 1024, maxRequestSize 5 * 1024 * 1024) WebServlet(/admin/roomtype) public class RoomTypeAdminServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part req.getPart(image); String imagePath ; if (part ! null part.getSize() 0) { String ext .jpg; String fileName UUID.randomUUID().toString().replace(-, ) ext; String uploadDir D:/hotel_upload; File dir new File(uploadDir); if (!dir.exists()) dir.mkdirs(); part.write(uploadDir / fileName); imagePath /upload/ fileName; } roomTypeRepo.updateImage(Integer.parseInt(req.getParameter(id)), imagePath); resp.sendRedirect(req.getContextPath() /admin/roomtype?actionlist); } }MultipartConfig是必须的不加的话调用req.getPart直接抛异常。maxFileSize和maxRequestSize单位都是字节2MB和5MB对房型展示图足够。文件名用UUID重命名避免用户上传的a.jpg在同一个目录下互相覆盖。关键点上传文件不要写进Tomcat的webapp目录因为重新部署WAR时整个目录会被替换文件会丢。数据库存的是/upload/xxx.jpg这个相对URL页面显示时再拼contextPath。课程设计如果不想折腾Tomcat虚拟目录可以临时写到webapp/upload下演示但要清楚这只是临时方案。JSP里显示图片的写法是。6. 上线前避坑清单五个最容易让项目重做的隐患与排查方法前面五章把架子搭起来不难真正让课程设计和实际演示翻车的往往不是业务代码而是下面五个隐形问题。按现象、原因、排查解决来写每一条都是反复踩过的。6.1 部署后CSS和图片全部失效现象本地启动一切正常部署到服务器或换个应用名后样式全丢、图片全裂。原因JSP里用了相对路径比如css/style.css在URL层级深的页面里就解析成错误地址。排查打开浏览器开发者工具看资源请求的实际URL再确认是否多了目录层级。解决把所有link、script、img标签改成${pageContext.request.contextPath}/css/style.css或统一用c:url生成带前缀的路径。6.2 中文乱码按固定顺序解决现象表单提交中文变成问号数据库存进去也是问号。原因setCharacterEncoding调用晚于第一次getParameter或JSP文件本身不是UTF-8保存或数据库连接URL没指定encoding。排查在doPost第一行就执行req.setCharacterEncoding(UTF-8)JSP首行保持pageEncodingUTF-8数据库连接URL追加useUnicodetruecharacterEncodingUTF-8。排查顺序就按这三处来缺一不可。6.3 F5刷新导致订单重复提交现象用户点一次预订没反应又点一下数据库出现两条相同订单。原因前端按钮没禁用后端没有token校验提交成功后forward回了原页面浏览器地址栏还停留在POST的URL上。排查按第4章的三件套检查按钮disabled、session token校验并remove、成功后sendRedirect到独立结果页。少一个都会留坑。6.4 跑一下午报too many connections现象应用刚启动正常用一段时间后MySQL连接数被打满重启Tomcat恢复。原因DAO里关连接的代码写在try末尾中途抛异常跳过关闭或只关了Connection没关PreparedStatement和ResultSet。排查finally块里逆序关闭rs、ps、conn并改用连接池替代DriverManager。连接池配置maxActive20开启logAbandoned后能在日志里看到具体哪个方法泄漏连接。6.5 JSP改了浏览器还在显示旧页面现象修改JSP后重启Tomcat刷新还是旧内容。原因Tomcat的JSP编译缓存存在work目录重新部署时没清理浏览器本地缓存也会缓存JSP生成的HTML。排查部署前删除Tomcat的work/Catalina目录开发时勾选开发者工具的Disable cache部署别用“覆盖部署”改成clean后重新reload。这套系统做到最后我最大的教训是ServletJSP项目的问题从来不在写不出来而在写完之后的部署和状态一致性维护。现在每交付一版我的习惯是先跑一遍五步体检登录后订一间房、刷新页面看订单是否重复、后台确认入住、退房后看房间状态是否释放、再检查控制台有没有连接泄漏。有一回就是漏了work目录清理现场演示时页面一直显示旧样式黑匣子一样的JSP缓存差点让我以为是代码写错。后来把体检清单固定在部署流程里再没在这上面翻过车。这个方向值得做但请一定先定好事务边界和状态流转再动手写页面。希望帮到你。本文还有配套的精品资源点击获取