简介:这是一份基于JSP与MySQL实现的网上订餐管理系统完整课程设计工程,适合正在学习Java Web开发、准备课程设计答辩的学生使用。项目围绕前端点餐与后台管理两条主线,实现用户注册登录、菜谱添加与推荐、购物车下单、订单支付、用户配送地址维护、商家信息修改等常用功能,同时包含管理员对菜品、推荐位、订单及用户信息的增删管理,业务覆盖面完整。压缩包共138个文件,核心包括41个Java源文件及对应字节码文件、19个JSP页面、8个Jar依赖包,还有SQL脚本、项目配置文件、说明文档等,整体仅2.52MB,目录结构清晰,便于导入Eclipse或IDEA中查看与运行。目前已有504人学习下载,可结合源码中的实体类、数据访问实现类和控制器,梳理从页面请求到数据库操作的完整调用链,理解分层开发、连接池配置及会话控制等典型写法。除完成订餐业务外,项目还保留了菜品图片等静态资源,适合进一步扩展评价、统计等模块,也可直接作为其他餐饮管理场景的课程设计基础。
1. 网上订餐管理系统:JSP+MySQL 课程设计的完整闭环
拿到一套 JSP+MySQL 网上订餐管理系统源码,第一件事不是点开 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 用户侧:注册、登录、个人信息与配送地址维护
用户线是整套系统所有流程的入口。注册页把表单提交给注册 Servlet,Servlet 里先执行一次SELECT COUNT(*)判断用户名是否已被占用,再决定返回「用户名已存在」还是继续插入。这个「先查再插」的逻辑看着简单,但正是课程设计和真实项目的分水岭——真实项目还会在表上加唯一索引做兜底,后面避坑那章我会细说这个翻车点。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>用户注册</title> </head> <body> <form action="${pageContext.request.contextPath}/register" method="post"> 用户名:<input type="text" name="username" required/> 密码:<input type="password" name="password" required/> 手机号:<input type="text" name="phone"/> 配送地址:<input type="text" name="address"/> <button type="submit">注册</button> </form> </body> </html>form 的 action 用了${pageContext.request.contextPath}拼出项目根路径,而不是写死/orders/register,这样项目换个部署名也不会失效。required是 HTML5 自带校验,能挡住一部分空提交,但 Servlet 里仍然要再判一次空——浏览器校验只是用户体验,服务端校验才是底线。
登录和注册套路一致,只是把查出来的 User 对象放进 Session,页面跳回首页。修改个人信息和配送地址走的是同一个 UserUpdateServlet,靠请求参数type区分走哪个分支。常见做法是type=info更新手机号、type=address更新配送地址,两个分支分别调 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 ) ENGINE=InnoDB 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推荐' ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB COMMENT='订单明细表'; CREATE TABLE t_admin ( admin_id INT AUTO_INCREMENT PRIMARY KEY, admin_name VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINE=InnoDB COMMENT='管理员表'; CREATE TABLE t_message ( message_id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, content TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB 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" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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」,你不能只回「抄来的」。推荐一组课程设计够用的配置:
| 参数 | 建议值 | 作用 |
|---|---|---|
| initialSize | 5 | 启动时预建 5 个连接 |
| maxTotal | 20 | 连接总数上限 |
| maxIdle | 10 | 空闲连接最多保留 10 个 |
| maxWaitMillis | 3000 | 拿不到连接时最多等 3 秒 |
| validationQuery | SELECT 1 | 借出连接前探活 |
maxTotal=20的依据是:课程设计的并发量也就是几个浏览器窗口同时点,20 个连接绰绰有余;再大反而拖垮 MySQL。maxWaitMillis=3000的意思是连接池满了就快速失败,而不是无限等待把请求全都挂死。validationQuery会在每次借出连接时执行一次SELECT 1,确保拿到的连接没被 MySQL 服务端断开,这一点在 MySQLwait_timeout默认 8 小时的环境里尤其重要。
3.3 DAO 层分工:OrdersDAOImpl、MenuDAOImpl 的代码套路
项目里 DAO 的命名很规范:实体类叫 Menu、Orders、Person,对应的实现类叫 MenuDAOImpl、OrdersDAOImpl、PersonDAOImpl。这是标准的 DAO 模式——接口定义方法,实现类写 JDBC 细节,Servlet 只面向接口编程。这样的好处是以后换 ORM 框架(比如 MyBatis),Servlet 层不用动,只换实现类。
public class MenuDAOImpl implements MenuDAO { private Connection conn; private PreparedStatement pstmt; private ResultSet rs; @Override public List<Menu> findAll() { List<Menu> 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 表单到 Servlet:@WebServlet 注解与请求参数接收
先看添加菜品这个典型动作。菜品表单提交到 AddMenuServlet,Servlet 接收参数、封装成 Menu 对象、调 MenuDAOImpl 落库,然后决定是跳转还是转发。用 @WebServlet 注解就不用改 web.xml,Tomcat 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;charset=UTF-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 老项目里事故率最高的功能。表单必须加enctype="multipart/form-data",Servlet 要加 @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. 避坑与排查:JSP+MySQL 项目最常见的 5 个翻车现场
这一章是实打实的血泪经验。下面五条几乎覆盖了课程设计从部署到演示阶段 80% 的翻车现场,每条按「现象、原因、解决」写,遇到问题直接对照着查。
5.1 中文乱码:JSP、Servlet、MySQL 三层编码打架
现象:页面标题正常,但从表单提交的中文入库后全是??,或者 JSP 页面本身显示乱码。
原因:编码没统一。JSP 文件头没写pageEncoding="UTF-8",Servlet 里没执行request.setCharacterEncoding("UTF-8"),JDBC URL 没带characterEncoding=utf8,MySQL 表默认字符集是 latin1——这四层任何一层是旧编码,中文就断在那一层。
解决:按顺序排查。JSP 头加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;每个 Servlet 的 doPost 第一行加request.setCharacterEncoding("UTF-8");JDBC URL 拼上useUnicode=true&characterEncoding=utf8;建库建表统一 utf8mb4。另外 Tomcat 8.5 之后 GET 请求默认 UTF-8,但如果你被分配到一个老 Tomcat 7 环境,GET 传中文还需要改 server.xml 里的URIEncoding="UTF-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 还必须带serverTimezone=Asia/Shanghai,否则报时区错误。
解决:驱动 JAR 放到WEB-INF/lib下并确认构建路径包含它;MySQL 5.7 配 5.1.49 驱动、MySQL 8.0 配 8.0.x 驱动是稳妥组合,别混用;URL 统一写成jdbc:mysql://localhost:3306/orders_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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。页面不刷新时先Ctrl+F5强刷,还不行就删掉 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分支里导致部分路径执行不到。连接池maxTotal=20,泄漏二十次整个应用就假死,而且重启 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,结果演示现场第二次下单,明细表里落了一半数据,场面一度很狼狈。从那以后,我每写一个带主从表的模块,都强制把事务边界和状态流转先画在纸上再动手,这个习惯一直留到今天。这套 JSP+MySQL 订餐系统的底子不错,把这两处改完,它就不再是「能跑的作业」,而是「能讲的系统」。希望帮到你。
本文还有配套的精品资源,点击获取