JSP+MySQL网上订餐系统课程设计实战:数据库设计与避坑指南
简介这是一份基于JSP与MySQL实现的网上订餐管理系统完整课程设计工程适合正在学习Java Web开发、准备课程设计答辩的学生使用。项目围绕前端点餐与后台管理两条主线实现用户注册登录、菜谱添加与推荐、购物车下单、订单支付、用户配送地址维护、商家信息修改等常用功能同时包含管理员对菜品、推荐位、订单及用户信息的增删管理业务覆盖面完整。压缩包共138个文件核心包括41个Java源文件及对应字节码文件、19个JSP页面、8个Jar依赖包还有SQL脚本、项目配置文件、说明文档等整体仅2.52MB目录结构清晰便于导入Eclipse或IDEA中查看与运行。目前已有504人学习下载可结合源码中的实体类、数据访问实现类和控制器梳理从页面请求到数据库操作的完整调用链理解分层开发、连接池配置及会话控制等典型写法。除完成订餐业务外项目还保留了菜品图片等静态资源适合进一步扩展评价、统计等模块也可直接作为其他餐饮管理场景的课程设计基础。1. 网上订餐管理系统JSPMySQL 课程设计的完整闭环拿到一套 JSPMySQL 网上订餐管理系统源码第一件事不是点开 Eclipse 跑起来而是先看类名。OrdersDAOImpl、PersonDAOImpl、MenuDAOImpl、AddMenuServlet、UserUpdateServlet、DbcpConnectionPool——这些名字排在一起项目的分层就写在脸上了JSP 做展示Servlet 收请求DAO 管数据库连接池统一管理 Connection。这套课程设计覆盖了订餐业务的主干流程用户注册登录、维护个人信息和配送地址、浏览菜谱、加购物车、下单支付商家侧能维护菜谱、推荐菜品、修改商家介绍。功能量恰好卡在「讲得清楚」和「不显单薄」之间。适合刚学完 JSP/Servlet、需要一个完整 CRUD 练手作业的学生也适合要快速搭演示项目的同学。下面按模块边界、表结构、Servlet 链路、踩坑记录四块展开全程按我拆过的同类项目的习惯来走。2. 三条业务线拆解用户、商家、订单怎么各管各的摘要里的功能清单看着有 17 项其实按角色一归类就三条线顾客干的、商家干的、跟订单有关的。上手最快的路径不是逐行读代码而是先按业务线把功能认全再去看对应类。业务线功能核心类/Servlet用户线注册、登录、退出、修改个人信息、修改配送地址、查看用户信息PersonDAOImpl、UserInfoDAOImpl、UserUpdateServlet商家线添加/删除/修改菜品、添加/删除推荐菜品、修改商家介绍Menu、MenuDAOImpl、AddMenuServlet订单线购物车删除、下单信息、订单支付Orders、OrdersDAOImpl系统线添加/删除管理员MessageDAOImpl、AdminServlet注意订单线里「删除购物车订单」和「下单信息」不是一个东西购物车是下单前的临时数据可以落在 Session 里也可以落库下单信息是订单主表和明细表一旦生成就要跟用户、菜品绑定。理解了这个区别后面查 bug 时才不会把两个 DAO 方法搞混。2.1 用户侧注册、登录、个人信息与配送地址维护用户线是整套系统所有流程的入口。注册页把表单提交给注册 ServletServlet 里先执行一次SELECT COUNT(*)判断用户名是否已被占用再决定返回「用户名已存在」还是继续插入。这个「先查再插」的逻辑看着简单但正是课程设计和真实项目的分水岭——真实项目还会在表上加唯一索引做兜底后面避坑那章我会细说这个翻车点。% page contentTypetext/html;charsetUTF-8 languagejava % html head title用户注册/title /head body form action${pageContext.request.contextPath}/register methodpost 用户名input typetext nameusername required/ 密码input typepassword namepassword required/ 手机号input typetext namephone/ 配送地址input typetext nameaddress/ button typesubmit注册/button /form /body /htmlform 的 action 用了${pageContext.request.contextPath}拼出项目根路径而不是写死/orders/register这样项目换个部署名也不会失效。required是 HTML5 自带校验能挡住一部分空提交但 Servlet 里仍然要再判一次空——浏览器校验只是用户体验服务端校验才是底线。登录和注册套路一致只是把查出来的 User 对象放进 Session页面跳回首页。修改个人信息和配送地址走的是同一个 UserUpdateServlet靠请求参数type区分走哪个分支。常见做法是typeinfo更新手机号、typeaddress更新配送地址两个分支分别调 UserInfoDAOImpl 里不同的 update 方法。WebServlet(/user/update) public class UserUpdateServlet extends HttpServlet { private UserInfoDAO userInfoDAO new UserInfoDAOImpl(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String type request.getParameter(type); // info个人信息, address配送地址 HttpSession session request.getSession(); User user (User) session.getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } boolean flag false; if (info.equals(type)) { String phone request.getParameter(phone); user.setPhone(phone); flag userInfoDAO.updatePhone(user.getUserId(), phone); } else if (address.equals(type)) { String address request.getParameter(address); user.setAddress(address); flag userInfoDAO.updateAddress(user.getUserId(), address); } if (flag) { session.setAttribute(user, user); // 回写 Session避免页面显示旧数据 response.sendRedirect(request.getContextPath() /user/info.jsp); } else { request.setAttribute(message, 更新失败); request.getRequestDispatcher(/user/info.jsp).forward(request, response); } } }这里有个新手最容易踩的坑数据库确实 update 成功了但 Session 里还是旧对象于是 JSP 个人信息展示页面刷新完还是老手机号、老地址。所以更新成功后要执行session.setAttribute(user, user)把改过的对象重新塞回去。response.sendRedirect用的是重定向会丢掉 request 里的 attribute所以失败时的错误提示必须走forward才能带到 JSP 页面里。2.2 商家侧菜谱管理、推荐菜品与商家介绍修改商家侧围绕 Menu 类和 MenuDAOImpl 展开。Menu 的字段一般有菜品名、价格、分类、图片路径、描述、是否推荐。这里提前说一个关键设计推荐菜品不是单独一张表而是 Menu 表里一个is_recommend字段1就是推荐0就是普通。所以「添加推荐菜品」和「删除推荐菜品」本质上都是 UPDATE 一条记录的标志位不是 INSERT 和 DELETE。面试或答辩被问到「推荐菜品怎么实现的」答出这一层就够了。修改商家介绍在这个项目里通常有两种做法单独建一张 shop_info 单行表或者把介绍文字写死在一个 JSP 页面里。前者更体面你还能顺势说一句「单独建表是为了以后做多门店扩展」后者省事但答辩容易被追问「这个配置放代码里运营怎么改」。既然项目里出现了 MessageDAOImpl 这样的留言相关类说明数据流动的套路是齐全的建议商家介绍也落一张表保持风格统一。菜谱管理页面的典型交互是三列按钮编辑、删除、推荐/取消推荐。编辑跳到一个回显菜品信息的 JSP提交后走 UpdateMenuServlet删除走 DeleteMenuServlet先要把菜品 id 通过 URL 参数传过去。WebServlet(/admin/deleteMenu) public class DeleteMenuServlet extends HttpServlet { private MenuDAO menuDAO new MenuDAOImpl(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int menuId Integer.parseInt(request.getParameter(menuId)); // 先检查是否被订单引用被引用则提示而不是直接删 boolean referenced menuDAO.isReferencedByOrder(menuId); if (referenced) { request.setAttribute(message, 该菜品已被订单引用不能物理删除建议下架); } else { menuDAO.deleteMenu(menuId); } response.sendRedirect(request.getContextPath() /admin/menuList); } }删除菜品之前先查关联这是我在真实项目里被教训过之后养成的习惯。课程设计的演示环境里没数据删除怎么点都成功等老师往库里导了批数据或者你自己拿真实订单测试时外键约束立刻会教做人。isReferencedByOrder这个检查方法在 MenuDAOImpl 里就是一条SELECT COUNT(*) FROM t_order_item WHERE menu_id ?成本极低但能挡住 90% 的尴尬报错。2.3 订单侧购物车、下单与支付状态流转订单侧是这套系统里逻辑最重的一条线也是答辩时老师最爱深挖的地方。购物车数据存在 Session 里的做法最常见用户点「加入购物车」Servlet 把菜品对象塞进 Session 里的一个 List 或 Map点「去结算」时拿出来算总价。好处是不碰数据库、实现简单坏处是购物车不跨会话保留浏览器一关就没了。下单动作是订单侧的核心它要干三件事往订单主表插一条记录拿到自增 ID、循环往订单明细表插每个菜品的快照、清空购物车。这段逻辑必须包进事务否则会出现「主表插入成功、明细表插入失败」的脏数据。很多课程设计源码这里就是三个独立 DAO 调用坏了也不知道后面的章节我会专门给出一版事务改造。订单支付在这里做得比较简订单表有个status字段0是未支付、1是已支付、2是已完成。用户点「去支付」Servlet 把 status 从 0 改成 1页面状态跟着变。这个简单的状态流转就是状态机的雏形理解了它后面接支付宝沙箱或者模拟支付都很顺。删除购物车订单则是另一条逻辑清掉 Session 里对应的购物车条目和订单表无关千万别调用了 OrdersDAOImpl 的删除方法。3. 数据库与 DAO 层六张表的设计和 DbcpConnectionPool 连接池课程设计项目里最容易拉开差距的不是页面写得有多花而是数据库设计能不能扛住几句追问。这套系统的核心表就六张用户表、菜谱表、订单主表、订单明细表、管理员表、留言表。把这几张表的字段和关系讲清楚答辩基本就稳了一半。3.1 六张核心表的设计字段、类型与一对多关系先给一版可以直接执行的建表 SQL字符集、存储引擎、外键都在里面复制到 Navicat 或命令行就能跑。CREATE DATABASE IF NOT EXISTS orders_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE orders_db; CREATE TABLE t_user ( user_id INT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码课程设计多为明文建议MD5, phone VARCHAR(20), address VARCHAR(200), reg_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT用户表; CREATE TABLE t_menu ( menu_id INT AUTO_INCREMENT PRIMARY KEY, menu_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, type VARCHAR(50) COMMENT 分类热菜/凉菜/主食, image VARCHAR(255) COMMENT 图片存储路径, description TEXT, is_recommend TINYINT DEFAULT 0 COMMENT 0普通 1推荐 ) ENGINEInnoDB COMMENT菜谱表; CREATE TABLE t_order ( order_id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, address VARCHAR(200), total_price DECIMAL(10,2), status TINYINT DEFAULT 0 COMMENT 0未支付 1已支付 2已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(user_id) ) ENGINEInnoDB COMMENT订单主表; CREATE TABLE t_order_item ( item_id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, menu_id INT NOT NULL, quantity INT DEFAULT 1, price DECIMAL(10,2) COMMENT 下单时快照价格, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order(order_id), CONSTRAINT fk_item_menu FOREIGN KEY (menu_id) REFERENCES t_menu(menu_id) ) ENGINEInnoDB COMMENT订单明细表; CREATE TABLE t_admin ( admin_id INT AUTO_INCREMENT PRIMARY KEY, admin_name VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINEInnoDB COMMENT管理员表; CREATE TABLE t_message ( message_id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, content TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT留言表;几个字段选择上的细节答辩时值得主动说出来。价格用DECIMAL(10,2)而不是DOUBLE因为浮点数算金额会有精度问题0.1 加 0.2 在二进制里都算不干净订单明细表里单独存一个price快照是因为菜谱价格可能被商家改过历史订单必须保留下单那一刻的价格is_recommend用TINYINT而不是VARCHAR存true/false省空间且查询快。外键约束在课程设计里建议保留因为老师看到表关系图能直接讲出「订单明细通过外键关联订单主表和菜谱表」这是一对多关系的标准示范。字符集用utf8mb4而不是utf8很多人栽在这。MySQL 的utf8实际是 utf8mb3最多存 3 字节用户昵称里带个 Emoji 直接插入失败utf8mb4 是完整版。建库时用DEFAULT CHARACTER SET utf8mb4后面所有表默认继承不用每张表重复写。3.2 DbcpConnectionPool 连接池参数含义与初始化时机项目里看到 DbcpConnectionPool 这个类说明用了 Apache DBCP 连接池。它的作用是把数据库连接的创建、复用、销毁统一管起来避免每次请求都DriverManager.getConnection现连一次。课程设计用 DBCP 是合理选择轻量、无需额外容器配置、比手写 JDBC 工具类高级一截又不像 Druid 那样需要引入一堆依赖。常见写法是静态代码块里初始化连接池整个应用只建一次。package com.orders.util; import org.apache.commons.dbcp2.BasicDataSource; import java.sql.Connection; import java.sql.SQLException; public class DbcpConnectionPool { private static BasicDataSource dataSource; static { dataSource new BasicDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/orders_db ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); dataSource.setMaxTotal(20); dataSource.setMaxIdle(10); dataSource.setMaxWaitMillis(3000); dataSource.setValidationQuery(SELECT 1); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }连接池参数是这套代码里最值得讨论的部分答辩老师问「为什么是 20 不是 100」你不能只回「抄来的」。推荐一组课程设计够用的配置参数建议值作用initialSize5启动时预建 5 个连接maxTotal20连接总数上限maxIdle10空闲连接最多保留 10 个maxWaitMillis3000拿不到连接时最多等 3 秒validationQuerySELECT 1借出连接前探活maxTotal20的依据是课程设计的并发量也就是几个浏览器窗口同时点20 个连接绰绰有余再大反而拖垮 MySQL。maxWaitMillis3000的意思是连接池满了就快速失败而不是无限等待把请求全都挂死。validationQuery会在每次借出连接时执行一次SELECT 1确保拿到的连接没被 MySQL 服务端断开这一点在 MySQLwait_timeout默认 8 小时的环境里尤其重要。3.3 DAO 层分工OrdersDAOImpl、MenuDAOImpl 的代码套路项目里 DAO 的命名很规范实体类叫 Menu、Orders、Person对应的实现类叫 MenuDAOImpl、OrdersDAOImpl、PersonDAOImpl。这是标准的 DAO 模式——接口定义方法实现类写 JDBC 细节Servlet 只面向接口编程。这样的好处是以后换 ORM 框架比如 MyBatisServlet 层不用动只换实现类。public class MenuDAOImpl implements MenuDAO { private Connection conn; private PreparedStatement pstmt; private ResultSet rs; Override public ListMenu findAll() { ListMenu list new ArrayList(); try { conn DbcpConnectionPool.getConnection(); String sql SELECT menu_id, menu_name, price, type, image, description, is_recommend FROM t_menu ORDER BY menu_id DESC; pstmt conn.prepareStatement(sql); rs pstmt.executeQuery(); while (rs.next()) { Menu menu new Menu(); menu.setMenuId(rs.getInt(menu_id)); menu.setMenuName(rs.getString(menu_name)); menu.setPrice(rs.getDouble(price)); menu.setType(rs.getString(type)); menu.setImage(rs.getString(image)); menu.setDescription(rs.getString(description)); menu.setRecommend(rs.getInt(is_recommend) 1); list.add(menu); } } catch (SQLException e) { e.printStackTrace(); } finally { close(); } return list; } private void close() { try { if (rs ! null) rs.close(); if (pstmt ! null) pstmt.close(); if (conn ! null) conn.close(); // 归还连接池不是真的断开 } catch (SQLException e) { e.printStackTrace(); } } }这段代码有两个值得抄进自己项目的习惯。一是PreparedStatement而不是字符串拼接 SQL——既能防 SQL 注入又不用手动处理单引号转义。二是关闭顺序必须是从 ResultSet 到 PreparedStatement 再到 Connection顺序反了会报连接还在使用中的错误。conn.close()在连接池模式下不是物理断开而是把连接归还给池子所以每个方法必须执行否则连接会越借越少直到池子被掏空。我一般会把close()抽成一个私有方法每个 DAO 方法在 finally 里调一次而不是在每个 catch 里写一堆rs.close()那样既啰嗦又容易漏。4. Servlet 控制层从表单提交到菜品入库的完整链路DAO 层解决的是「数据怎么存取」Servlet 层解决的是「请求怎么流转」。这套系统里 AddMenuServlet 和 UserUpdateServlet 是控制层的代表把它们的链路理清其余 Servlet 都是同一套模板。4.1 表单到 ServletWebServlet 注解与请求参数接收先看添加菜品这个典型动作。菜品表单提交到 AddMenuServletServlet 接收参数、封装成 Menu 对象、调 MenuDAOImpl 落库然后决定是跳转还是转发。用 WebServlet 注解就不用改 web.xmlTomcat 7 之后的版本都支持。WebServlet(/admin/addMenu) public class AddMenuServlet extends HttpServlet { private MenuDAO menuDAO new MenuDAOImpl(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String name request.getParameter(name); String priceStr request.getParameter(price); String type request.getParameter(type); String description request.getParameter(description); if (name null || name.trim().isEmpty() || priceStr null) { request.setAttribute(message, 菜品名和价格不能为空); request.getRequestDispatcher(/admin/menu_add.jsp).forward(request, response); return; } Menu menu new Menu(); menu.setMenuName(name.trim()); menu.setPrice(Double.parseDouble(priceStr)); menu.setType(type); menu.setDescription(description); boolean flag menuDAO.addMenu(menu); if (flag) { response.sendRedirect(request.getContextPath() /admin/menuList); } else { request.setAttribute(message, 添加失败请检查数据库连接); request.getRequestDispatcher(/admin/menu_add.jsp).forward(request, response); } } }注意成功和失败的分流成功用sendRedirect重定向到列表页这样用户刷新浏览器不会重复提交表单失败用forward转发回添加页并带上错误提示。很多人分不清这两个方法的区别核心就一句话——重定向是浏览器重新发一次请求request 里的 attribute 全部丢失转发是服务端内部跳转attribute 还能带到目标页面。所以「回显错误信息」一律用 forward「完成后跳走」一律用 redirect。参数接收上Double.parseDouble(priceStr)没有做异常捕获这是课程设计的常态但真要交上去建议加一个 try-catch 提示「价格格式不正确」。4.2 菜品图片上传与页面定位Multipart 和路径的坑菜品带图片是这个系统的加分项但图片上传恰恰是 JSP 老项目里事故率最高的功能。表单必须加enctypemultipart/form-dataServlet 要加 MultipartConfig 注解才能用request.getPart()取文件。MultipartConfig(maxFileSize 2 * 1024 * 1024, maxRequestSize 10 * 1024 * 1024) WebServlet(/admin/addMenu) public class AddMenuServlet extends HttpServlet { // 接上文在 doPost 里处理图片 Part part request.getPart(image); if (part ! null part.getSize() 0) { String fileName UUID.randomUUID().toString().replace(-, ) .jpg; String uploadDir getServletContext().getRealPath(/uploads); File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); // 目录第一次访问往往不存在必须建 } part.write(uploadDir File.separator fileName); menu.setImage(uploads/ fileName); // 数据库只存相对路径 } }getRealPath(/uploads)拿到的是 Tomcat 发布目录里的物理路径这个目录在项目第一次部署时通常不存在所以必须先mkdirs()不然part.write直接抛IOException。数据库里存相对路径uploads/xxx.jpg而不是绝对路径因为绝对路径带着本机磁盘符换个机器部署就全裂。JSP 展示时用img src${pageContext.request.contextPath}/uploads/xxx.jpg拼出完整访问路径。有同学问 JSP 里图片怎么对坐标定位——其实页面定位跟图片本身没关系是 CSS 的事。常见做法是给 img 一个固定宽高加object-fit: cover让图片不变形裁剪再用 flex 或 grid 控制它在卡片里的位置不需要任何坐标 API。.dish-card img { width: 120px; height: 120px; object-fit: cover; /* 统一裁成正方形避免图片拉伸变形 */ border-radius: 8px; }4.3 登录拦截与退出控制Session 的正确清理方式用户登录状态靠 Session 维持但只存不拦等于没做。课程设计里常见的错误是每个 JSP 页面手动判断 Session 是否为空写得到处都是 if 判断漏一个页面就裸奔。规范做法是用 Filter 统一拦截需要登录的路径。WebFilter(/user/*) 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); // 不强制创建新 Session if (session null || session.getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); // 已登录放行 } }/user/*这个映射拦截所有用户中心的请求包括个人信息页、下单页、修改地址。request.getSession(false)是精华——Session 不存在时返回 null 而不是创建一个新的避免未登录用户每次访问都被种一个无用的 Session。管理员后台同理可以用/admin/*再配一个 AdminFilter检查 Session 里有没有管理员对象。退出登录就简单了session.invalidate()销毁整个 Session然后重定向回首页。注意不要只removeAttribute(user)不销毁 Session那样购物车等其它 session 数据还留在服务端占内存不说还可能串号。5. 避坑与排查JSPMySQL 项目最常见的 5 个翻车现场这一章是实打实的血泪经验。下面五条几乎覆盖了课程设计从部署到演示阶段 80% 的翻车现场每条按「现象、原因、解决」写遇到问题直接对照着查。5.1 中文乱码JSP、Servlet、MySQL 三层编码打架现象页面标题正常但从表单提交的中文入库后全是??或者 JSP 页面本身显示乱码。原因编码没统一。JSP 文件头没写pageEncodingUTF-8Servlet 里没执行request.setCharacterEncoding(UTF-8)JDBC URL 没带characterEncodingutf8MySQL 表默认字符集是 latin1——这四层任何一层是旧编码中文就断在那一层。解决按顺序排查。JSP 头加% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %每个 Servlet 的 doPost 第一行加request.setCharacterEncoding(UTF-8)JDBC URL 拼上useUnicodetruecharacterEncodingutf8建库建表统一 utf8mb4。另外 Tomcat 8.5 之后 GET 请求默认 UTF-8但如果你被分配到一个老 Tomcat 7 环境GET 传中文还需要改 server.xml 里的URIEncodingUTF-8。排查顺序建议是先开浏览器开发者工具看响应头 charset再查SHOW VARIABLES LIKE character_set%看 MySQL 侧最后才是看代码。5.2 数据库连接失败驱动类名、时区与 MySQL 版本差异现象启动 Tomcat 一访问带数据库操作的页面报ClassNotFoundException: com.mysql.jdbc.Driver或者报Communications link failure又或者报The server time zone value йʱ is unrecognized。原因三种情况要对号入座。驱动 JAR 没放进 WEB-INF/lib 而是丢在工程根目录运行时找不到类MySQL 版本和驱动不匹配——MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver老代码写com.mysql.jdbc.Driver虽然能跑但会有警告反过来 MySQL 5.7 用 cj 类名直接失败8.0 连接 URL 还必须带serverTimezoneAsia/Shanghai否则报时区错误。解决驱动 JAR 放到WEB-INF/lib下并确认构建路径包含它MySQL 5.7 配 5.1.49 驱动、MySQL 8.0 配 8.0.x 驱动是稳妥组合别混用URL 统一写成jdbc:mysql://localhost:3306/orders_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai。装了 MySQL 8.0 的同学尤其注意网上很多老教程的驱动类名和时区参数都是 5.7 时代的照抄必翻车。5.3 Tomcat 端口占用与旧 Session 残留现象启动报Port 8080 required by Tomcat v9.0 Server at localhost is already in use或者明明改过代码重启了页面还是老样子。原因上一个 Tomcat 实例没退干净8080 被残留进程占着。页面不刷新则是两个叠加问题浏览器缓存了旧 JSP以及 Tomcat 的工作目录wtpwebapps里还留着旧编译产物。解决命令行执行netstat -ano | findstr 8080拿到 PID 后taskkill /F /PID pid杀掉残留进程或者直接改 Tomcat 的 server.xml 把端口换到 8081。页面不刷新时先CtrlF5强刷还不行就删掉 Eclipse 里.metadata下对应工程的 wtpwebapps 目录再重启。我一般习惯直接用 8081 端口部署课程设计避开本机其它 Java 项目占用 8080 的冲突。5.4 连接池被拖死连接不归还的典型症状现象项目刚启动一切正常点着点着突然所有查询都卡死日志里一片Cannot get a connection, pool exhausted或wait for connection timeout。原因DAO 方法里连接没还。最常见的是只关了 ResultSet 和 PreparedStatement漏了conn.close()或者把conn.close()写在if分支里导致部分路径执行不到。连接池maxTotal20泄漏二十次整个应用就假死而且重启 Tomcat 才能恢复。解决每个 DAO 方法的 finally 块里统一关闭三个资源顺序 ResultSet → PreparedStatement → Connection。更好的做法是像我前面 3.3 节那样封装一个closeAll()私有方法所有方法共用杜绝漏关。如果用的是 JDK 7还可以用 try-with-resources 语法让 JVM 自动关try (Connection conn DbcpConnectionPool.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { // 执行查询ResultSet 也放进 try 括号里 }注意 try-with-resources 中连接池返回的 Connection 被关闭时同样是归还而不是物理断开行为一致可以放心用。5.5 删除菜品外键报错先处理关联再删主表现象后台删除一个菜品页面提示删除成功但 MySQL 日志或控制台抛Cannot delete or update a parent row: a foreign key constraint fails菜品实际还在表里。原因此前的订单明细t_order_item里还有记录引用这个menu_id外键约束不允许删有子记录的主表行。课程设计里最容易触发这个错因为测试时会真的下单下过单的菜就成了「有历史包袱」的菜。解决两种思路。删菜前先查SELECT COUNT(*) FROM t_order_item WHERE menu_id ?被引用过就不允许物理删除而是把is_recommend置 0 下架这是真实电商的常见策略——历史订单必须能查到当时的商品快照如果一定要物理删就先删t_order_item里关联行再删t_menu但这样历史订单的明细就没了演示时被问到会很难看。课程设计推荐前者。还有一点容易混淆「删除购物车订单」如果是删 Session 里的数据不涉及外键如果购物车也落库了删购物车条目时如果它外键关联了菜品表同样要先处理关联。6. 答辩加分下单事务与订单状态机的实战改造最后给两个能直接写进代码里的加分改造都不复杂但对「能不能讲清楚」的提升是质的。6.1 下单事务订单主表和明细表要么都成功要么都回滚原版下单逻辑如果是三个独立 DAO 调用那就有个隐患主表插入成功、明细表插入时挂了钱付了订单却是空的。改造方式是在 Service 层或 Servlet 里手动管理事务。Connection conn null; try { conn DbcpConnectionPool.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启手动事务 // 1. 插入订单主表拿回自增ID PreparedStatement ps1 conn.prepareStatement( INSERT INTO t_order(user_id, address, total_price, status) VALUES (?,?,?,0), Statement.RETURN_GENERATED_KEYS); ps1.setInt(1, userId); ps1.setString(2, address); ps1.setBigDecimal(3, totalPrice); ps1.executeUpdate(); ResultSet keys ps1.getGeneratedKeys(); int orderId 0; if (keys.next()) { orderId keys.getInt(1); } // 2. 循环插入订单明细 for (CartItem item : cart) { PreparedStatement ps2 conn.prepareStatement( INSERT INTO t_order_item(order_id, menu_id, quantity, price) VALUES (?,?,?,?)); ps2.setInt(1, orderId); ps2.setInt(2, item.getMenuId()); ps2.setInt(3, item.getQuantity()); ps2.setBigDecimal(4, item.getPrice()); ps2.executeUpdate(); ps2.close(); } conn.commit(); // 全部成功提交 cart.clear(); // 清空购物车 } catch (Exception e) { if (conn ! null) { conn.rollback(); // 任何一步失败整体回滚 } e.printStackTrace(); } finally { if (conn ! null) { conn.setAutoCommit(true); // 恢复自动提交归还连接池 conn.close(); } }这段代码是标准的手动事务模板。关键点有三个conn.setAutoCommit(false)之后所有 SQL 都不会真正落库直到commit()catch里rollback()把已经执行的 insert 全部撤销finally 里要先把autoCommit恢复成 true 再归还连接否则连接池里的连接下次借出时还是手动提交模式别的请求会莫名丢数据。答辩时把这个讲清楚已经超过绝大多数只贴 CRUD 的课程设计。6.2 订单状态机让演示流程更可信订单的status字段从 0 到 1 到 2表面看是改数字实际上是一个微型状态机。给每个状态定义清楚入口和出口页面按钮也跟着状态变演示时会非常顺。状态值含义触发动作页面表现0未支付用户提交订单显示「去支付」按钮1已支付用户点击支付显示「等待商家接单」2已完成商家确认送达显示「确认收货」状态流转的 SQL 建议封装成一个带条件判断的更新语句防止状态乱跳UPDATE t_order SET status 1 WHERE order_id ? AND status 0; -- 只允许从 0 到 1WHERE status 0这个条件就是状态机的约束防止用户连点两次支付把状态从 1 再改回 0。演示前还有一个小习惯值得养成——预置一批数据两三个用户、七八道菜、一两笔已支付的订单。老师点开页面看到的是有内容的后台而不是空荡荡的表格演示的说服力差别很大。我自己当年交课程设计时栽过的跟头就是砍掉事务、直接在 Servlet 里串行执行 insert结果演示现场第二次下单明细表里落了一半数据场面一度很狼狈。从那以后我每写一个带主从表的模块都强制把事务边界和状态流转先画在纸上再动手这个习惯一直留到今天。这套 JSPMySQL 订餐系统的底子不错把这两处改完它就不再是「能跑的作业」而是「能讲的系统」。希望帮到你。本文还有配套的精品资源点击获取