☰
JavaWeb实战:从零构建校园订餐系统,打通Servlet+MySQL全链路
2026/9/29 19:33:20 网站建设 项目流程

简介:这是一份基于JavaWeb的校园订餐系统完整项目源码,定位为JavaWeb学习者的实战案例,也适合作为毕业设计或课程设计的参考蓝本。压缩包共723个文件,大小约9.8MB,其中JSP与Java等后台程序文件构成核心业务逻辑,编译产物与前端样式脚本负责页面交互展示,大量图片素材用于界面装饰与菜品展示,SQL数据库脚本可还原表结构及初始数据,依赖库文件则确保项目正常运行,整体目录结构清晰,便于按模块对照学习。目前已有220人学习下载。通过研读这套代码,可以理解用户登录验证、菜品查询、订单提交等典型功能的实现流程,掌握Servlet请求处理、JSP页面渲染以及MySQL数据库操作的协同方式;同时项目中包含的配置文件与部署描述符,也有助于熟悉JavaWeb项目的标准组织方式,对提升实际动手能力与毕业设计完成效率都有直接帮助。

1. 校园订餐系统:为什么它是被低估的JavaWeb实战项目

很多人觉得校园订餐系统“太简单”,不过是几个JSP页面加几个Servlet。但真正动手做过的人知道,把用户注册、菜品浏览、购物车、下单支付、商家接单这一整条链路跑通,你已经踩过了JavaWeb项目百分之八十的坑。这个标题里的校园订餐系统,说的是用JavaWeb技术栈(Servlet/JSP + MySQL + Maven + Tomcat)实现一个完整的在线订餐网站,覆盖从学生登录、点餐、提交订单到商家处理订单的完整业务闭环。它适合三类人:正在找JavaWeb课程设计案例源码的在校生、想验证自己后端基本功的转行者、以及需要一个完整项目来补齐MySQL与Servlet协作经验的Java初学者。这个项目的价值不在于功能多花哨,而在于它足够小、足够完整,能让你把一条请求从浏览器到数据库的全过程彻底看清。

2. 技术选型与项目骨架:先想清楚再用IDEA跑起来

2.1 技术选型:Servlet还是SSM?校园订餐系统的三种组合

做校园订餐系统,最常见的做法有三条路:纯Servlet + JSP + JDBC、Servlet + MyBatis、Spring MVC + Spring + MyBatis(SSM)。三者没有绝对好坏,主要看你手里有多少调试时间。

技术组合代码量维护难度适合场景
Servlet + JSP + JDBC最少高课程设计、理解HTTP与JDBC底层
Servlet + MyBatis中等中有Maven基础、想稳一点
SSM框架多低为实习或就业准备、有充足排错时间

我一般建议课程设计选方案1或2。方案1的好处是你能直观看到HTTP请求如何进入Servlet、SQL如何被提交到MySQL执行,这是javaweb项目完整案例里最值得保留的教学价值。方案2则让你提前熟悉MyBatis的mapper映射,后续迁移Spring Boot时阻力小很多。至于SSM,如果你没有至少两周的余量,尽量不要碰,配置文件互相引用的排错会让你消耗大量时间。

这里有一个关键点:无论选哪种,数据库交互层的连接管理不要裸写JDBC。至少接一个连接池(Druid或HikariCP),否则每次请求都new Connection,Tomcat在高并发下几分钟就崩。另外,如果这个项目要作为JavaWeb课程设计案例源码提交,答辩时老师一定会问Servlet的线程模型——Servlet默认是多线程的,多个请求会并发执行同一个Servlet实例的service方法,所以实例变量不能存请求相关的数据。这个点几乎每次答辩都会问到。

2.2 数据库设计:用户、菜品、订单、订单明细的四张核心表

校园订餐系统的业务不复杂,核心表就四张:用户表、菜品表、订单表、订单明细表。但字段设计上有几个坑值得注意。

用户表:

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(64) NOT NULL COMMENT '密码(加盐哈希)', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '收餐人姓名', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `address` VARCHAR(255) DEFAULT NULL COMMENT '默认送餐地址', `role` TINYINT DEFAULT 1 COMMENT '1-学生 2-商家 3-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

一个常见错误是用户名不建唯一索引。校园订餐系统如果允许多个同名用户注册,登录时查库就会返回多条记录,Session里存的user对象到底是哪一条全看数据库返回顺序,纯玄学。另外password字段别用varchar(32)存明文,至少做一次MD5加盐。虽然校园项目不会有人来攻击,但把这个习惯养成了,面试时问到密码存储你不会心虚。

菜品表、订单表和明细表的字段要对应好:

CREATE TABLE `dish` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '菜名', `price` DECIMAL(10,2) NOT NULL COMMENT '单价', `image` VARCHAR(255) DEFAULT NULL COMMENT '图片路径', `category` VARCHAR(20) DEFAULT NULL COMMENT '分类:快餐/饮品/主食', `stock` INT DEFAULT 0 COMMENT '库存', `status` TINYINT DEFAULT 1 COMMENT '1-上架 0-下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` INT NOT NULL COMMENT '下单用户', `total_price` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0-待支付 1-已支付待出餐 2-已出餐 3-已送达 4-已取消', `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; CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `dish_id` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '下单时的快照价格', `quantity` INT NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单明细里的price存的是下单那一刻的价格快照,而不是关联菜品的实时价格。菜品后来涨价,用户的历史订单不能跟着变。这个细节面试官很爱问,很多人没做对。另外,表和表之间不要建数据库层面的外键约束,课程设计里画ER图方便,但实际运行中外键会让每次插入和删除都多一层约束检查,订单表写入频繁时性能损耗明显。业务一致性交给service层代码去保证就好。

2.3 Maven项目结构与IDEA运行配置

工程结构推荐Maven单模块的标准布局,IDEA里跑JavaWeb项目配置这件事,很多新手卡在目录结构不对:

campus-order/ ├── pom.xml └── src └── main ├── java │ └── com/campus/order │ ├── servlet/ # 所有Servlet │ ├── service/ # 业务逻辑 │ ├── dao/ # 数据访问 │ ├── entity/ # 实体类 │ └── util/ # 工具类 ├── resources │ └── db.properties # 数据库配置 └── webapp ├── WEB-INF │ └── web.xml ├── css/ js/ img/ # 静态资源 └── jsp/ # 页面文件

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.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>

逻辑说明:Servlet和JSP的依赖scope必须设为provided,因为Tomcat自带这两套实现。如果你把它写成compile并打进了war包,部署到Tomcat会出现类冲突,报错指向NoSuchMethodError,排错路径非常绕。

参数说明:mysql-connector-java用5.1.49还是8.x,取决于你的MySQL版本。MySQL 5.7用5.1.x没问题,MySQL 8.0建议换8.0.x驱动,否则连库时可能报Public Key Retrieval is not allowed。

IDEA里运行这个项目的核心配置就三步:Project SDK选JDK 1.8或11,Maven用IDEA自带的即可;Edit Configurations里添加Tomcat Server,Deployment选项卡把war包挂上去;Application context填/campus。这个值决定JSP里相对路径的前缀,我习惯填/,前端代码不用带项目名前缀,但如果一个Tomcat里跑多个项目,还是用带名字的路径更清晰。

注意:IDEA里Tomcat端口被占用时,去Tomcat安装目录conf/server.xml找Connector节点改port,别盲目杀进程。很多教程让人改IDEA里的HTTP port,但Connector上的port才是真正监听端口。

另外,如果你跟的是教程视频,黑马javaweb笔记里推荐的目录分层和这里基本一致。核心是把Servlet、service、dao三类职责分开,哪怕代码写得糙,后续重构也有地方下手。

3. 核心功能实现:登录、购物车与订单状态流转

3.1 用户登录与Session管理:拦截器不是可选项

登录模块是整个系统里最容易出安全漏洞的地方。常规做法是登录成功后把user对象放进HttpSession,再用Filter统一拦截所有未登录请求。不要在Servlet里重复写session判断,代码散一地,后面加一个需要登录的接口你就得逐个人肉复制。

登录Servlet的核心逻辑:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() + "/jsp/index.jsp"); } }

逻辑说明:先统一设置请求编码,再取出用户名密码交给UserService校验。user为null说明查无此人或密码错误,用forward转发回登录页并带出错误信息。登录成功就把user对象放进Session,30分钟无操作过期,然后重定向到首页。

参数说明:setMaxInactiveInterval的单位是秒,30分钟对订餐场景合理。设太短,用户选餐途中Session被清,提交订单时被拦截器踢回登录页,那体验就是灾难。重定向用sendRedirect,参数必须拼上getContextPath(),否则在带项目名的部署环境下会跳到一个不存在的路径。

拦截器必须用Filter实现,这是很多人第一次做JavaWeb项目遗漏的环节:

@WebFilter("/*") public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String path = request.getRequestURI(); if (path.endsWith("/login") || path.endsWith("/register") || path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/img/")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/jsp/login.jsp"); return; } chain.doFilter(req, resp); } }

逻辑说明:getRequestURI返回的是带项目名的完整路径,用endsWith判断Servlet映射比contains更严谨,避免/loginxxx.jsp这种路径被意外放行。静态资源css/js/img要放行,否则登录页加载后没有样式。

参数说明:request.getSession(false)是拿已有Session而不是新建一个。如果这个请求还没登录,getSession(true)会强行创建一个空Session,导致拦截逻辑失效。这里false是必须的。静态资源的URL前缀取决于前端怎么写,如果前端写的是${ctx}/css/style.css,这段判断就用startsWith("/css/")。

3.2 购物车:用HttpSession还是数据库表?

购物车有两种实现路线:放HttpSession里,或建shopping_cart数据库表。课程设计和绝大多数校园场景,我推荐Session做购物车。用户未下单前购物车是临时数据,不需要持久化;存数据库要建表、写增删改查,业务价值几乎为零。

Session购物车的核心逻辑:

// 加入购物车 Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<Integer, Integer>(); } Integer dishId = Integer.valueOf(request.getParameter("dishId")); int quantity = Integer.parseInt(request.getParameter("quantity")); cart.put(dishId, cart.getOrDefault(dishId, 0) + quantity); session.setAttribute("cart", cart);

逻辑说明:cart是以菜品id为key、数量为value的Map。重复点击加入购物车时不会新增key,而是把已有数量加一。购物车图标的角标数字就是所有value加总,而不是size(),因为不同菜品数量可能大于1。

参数说明:这里用LinkedHashMap而不是HashMap。HashMap本身无序,你往购物车加菜的顺序在页面上foreach遍历时会错乱。用户说“我明明先加的回锅肉,显示出来排在最后面”,这就是HashMap的哈希顺序在作怪,LinkedHashMap按插入顺序遍历,体验完全不同。getOrDefault是JDK 8的方法,如果你还在用JDK 7,需要先判断containsKey再取值。

下单结算时从Session取出cart,逐条写入order_item表,注意必须在同一个事务里完成。这里有个边界:Session购物车有一个天然缺陷,用户换设备或清除浏览器缓存,购物车就没了。对校园订餐这种场景可以接受,但你要在答辩时能说清楚这个取舍。

3.3 订单生成与事务控制:一个接口里的四条SQL

下单动作涉及四步:生成订单主记录、写入订单明细、扣减菜品库存、清空购物车。任何一步失败都不能留下半截数据。这里必须用事务把四步包起来。

手写JDBC事务是理解事务边界最直接的方式:

public boolean createOrder(Order order, Map<Integer, Integer> cart, Map<Integer, BigDecimal> priceMap) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); PreparedStatement ps1 = conn.prepareStatement( "INSERT INTO orders (order_no, user_id, total_price, status) VALUES (?,?,?,0)", Statement.RETURN_GENERATED_KEYS); ps1.setString(1, order.getOrderNo()); ps1.setInt(2, order.getUserId()); ps1.setBigDecimal(3, order.getTotalPrice()); ps1.executeUpdate(); ResultSet keys = ps1.getGeneratedKeys(); int orderId = 0; if (keys.next()) orderId = keys.getInt(1); PreparedStatement ps2 = conn.prepareStatement( "INSERT INTO order_item (order_id, dish_id, price, quantity) VALUES (?,?,?,?)"); for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { ps2.setInt(1, orderId); ps2.setInt(2, entry.getKey()); ps2.setBigDecimal(3, priceMap.get(entry.getKey())); ps2.setInt(4, entry.getValue()); ps2.addBatch(); } ps2.executeBatch(); PreparedStatement ps3 = conn.prepareStatement( "UPDATE dish SET stock = stock - ? WHERE id = ? AND stock >= ?"); for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { ps3.setInt(1, entry.getValue()); ps3.setInt(2, entry.getKey()); ps3.setInt(3, entry.getValue()); ps3.addBatch(); } int[] updateCounts = ps3.executeBatch(); for (int count : updateCounts) { if (count == 0) { conn.rollback(); return false; } } conn.commit(); return true; } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new RuntimeException("下单失败", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

逻辑说明:三个动作按顺序执行在同一个Connection上。扣库存的SQL带stock >= ?条件,是经典的安全扣减:只有当库存够用时才更新成功,executeBatch返回的int数组里出现0就说明某道菜库存不足,触发rollback。

参数说明:Statement.RETURN_GENERATED_KEYS让数据库返回自增主键,用于关联明细表的外键。executeBatch()批量执行比循环里调executeUpdate快很多,购物车有几十个品类时差距明显。finally里必须先setAutoCommit(true)再close,因为连接池复用时如果不还原自动提交状态,下一次拿到的连接可能还开着事务。

还有一个细节:select菜品价格必须放在事务里,最好在事务开始时用SELECT price FROM dish WHERE id = ? FOR UPDATE锁住菜品行。否则下单过程中管理员改了价格,订单金额就对不上。这个坑在并发测试里才会暴露,平时单机调功能根本看不出来。

4. 避坑指南:能让你半夜改代码的5个经典问题

4.1 中文乱码:从JSP到MySQL的全链路编码不一致

现象:页面上菜名显示成“????”或者“菜名变成乱码符号”,提交的订单备注里的中文全部丢失。

原因:三个环节编码不一致。JSP页面没有指定pageEncoding,Tomcat读取请求参数时默认按ISO-8859-1解析,MySQL连接串没指定characterEncoding,三个环节各用各的编码就全乱套。

解决:三层同时做。JSP顶部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,web.xml里配置CharacterEncodingFilter强制所有请求和响应用UTF-8,MySQL连接串加上characterEncoding=utf8mb4。如果用Druid连接池,在properties里写url加useUnicode=true&characterEncoding=utf8mb4。三步做完,乱码基本绝迹。排查顺序从JSP到Filter再到数据库连接串,每一步单独验证,不要一次性全改然后猜是哪里修复的。

4.2 连接池配置:每次new Connection的代价

现象:一个50人的班级同时登录系统,Tomcat直接卡死,控制台报Too many connections或Connection refused。

原因:Servlet里每次请求都DriverManager.getConnection(),用完直接close,没有复用。MySQL默认max_connections是151,50人同时操作很容易打满。

解决:用Druid或HikariCP接管连接。Druid的关键参数就三个:initialSize、maxActive、maxWait。校园项目initialSize设5,maxActive设20,maxWait设5000毫秒足够。连接池不是越大越好,maxActive设100在校园场景反而会因为Tomcat自身线程池不足导致连接获取超时。单机Tomcat配20个活跃连接足够支撑几百人的并发访问,瓶颈通常在Tomcat线程池而不是数据库连接数。验证方法:压力测试过程中用show processlist观察数据库连接数是否达到maxActive上限。

4.3 JSP里的路径问题:/和相对路径的三种上下文

现象:在IDEA里跑一切正常,部署到Linux服务器后,点击菜单跳404,页面上的CSS和图片全部丢失。

原因:JSP里用了href="css/style.css"这类相对路径。当URL从/campus/jsp/index.jsp变成/campus/dish/list时,浏览器解析相对路径的基准目录变了,资源请求就发到了不存在的位置。

解决:在JSP页面统一用绝对路径。常见做法是每个JSP开头加<c:set var="ctx" value="${pageContext.request.contextPath}" />,然后所有资源路径写成${ctx}/css/style.css。如果你的容器上下文是/campus,生成的路径就是/campus/css/style.css。这个教训我是在项目上线第一天就踩到的,页面HTML源码看着没有任何问题,但所有静态资源全部404。排查方法:浏览器F12看Network标签,凡是带两个项目名的请求都是相对路径写错了。

4.4 事务失效:连接复用状态下忘记还原AutoCommit

现象:下单接口偶尔报“Transaction already active”或“Connection is closed”,订单数据出现半截——订单主表有记录,明细表缺失。

原因:Druid连接池复用连接时,上一次事务异常退出后没有把setAutoCommit(true)恢复回去,下一次从池里拿到的连接仍然处于事务模式,后续操作莫名其妙都被包在同一个事务里。

解决:finally块里必须执行setAutoCommit(true)再close。更稳妥的做法是service层方法内部用try-catch-finally同时处理rollback和restore两个动作。这里没有捷径,每次从连接池拿连接都要当它是一张被之前操作污染过的白纸来对待。记住一个习惯:事务代码的finally里先restore再close,顺序不能反。

4.5 图片上传与加载路径:IDEA工作目录和Tomcat部署目录不一致

现象:在IDEA里上传的菜品图片显示正常,打成war包部署到Linux后,所有图片丢失或404。

原因:IDEA里Tomcat的工作目录指向项目的target目录,上传文件写到了D:/workspace/campus/target/uploads,而部署到独立Tomcat后,相对路径变成/opt/tomcat/bin下的某处,两者完全不是一个位置。

解决:外部存储路径不要让程序自己猜。在properties里配置upload.path=/data/campus/uploads,代码里读取这个配置拼接文件路径。同时Tomcat的server.xml里为这个目录加一个Context映射,让/uploadsURL指向磁盘目录。自己本地写死相对路径确实爽,但上线必翻车。这个问题的排查方法很简单:登录服务器find / -name "uploadedImg*.jpg" 2>/dev/null,看看图片到底写到哪里去了。

5. 从本地到线上:部署、验证与下一步的三种玩法

5.1 部署到Linux:最小可行步骤

用Maven打包war文件,生成的campus-order.war放进Tomcat的webapps目录,启动Tomcat后war会自动解压。JSP编译报了错,优先看Tomcat logs目录下localhost开头的日志,里面有精确到行的错误信息。部署前确认JDK和Tomcat版本匹配,Java 8配Tomcat 8/9,Java 11配Tomcat 9/10。版本不匹配时会出现“JSP compiler does not support the current version”这类信息,看着像代码错误,实际是版本问题。数据库连接串里的localhost也要改成服务器IP,这个细节很多人第一次部署漏掉。

5.2 压测与验证:不要只看页面能打开

验证工具用JMeter最直接。创建线程组,50个线程循环10次,对登录接口和菜品列表接口做并发请求。关注P95响应时间和错误率两个指标。P95超过2秒,先看数据库慢查询日志,再看菜品查询有没有走索引,最后才考虑调连接池数量。这个排查顺序能省下大量时间。另外可以用curl -I检查静态资源响应头是否正常,确认服务器上CSS和图片的Content-Type没有变成text/html。

5.3 两种延伸改造

做完这个项目,两个常见延伸方向:一是把Servlet替换为Spring Boot的Controller,数据访问层从JDBC换成MyBatis,项目直接从JavaWeb升级到更接近企业开发的体系,简历上可以写的东西多不少。二是加一个简单管理后台,菜品上下架和订单状态修改通过管理端接口操作,而不是改数据库。这两个方向的业务逻辑都已经在这个源码里了,改造成本不高,收获实实在在。

我自己第一次做完校园订餐系统后,留了一个习惯:每次遇到部署环境不一致导致的404和乱码,都先想清楚是请求路径、编码还是存储位置的问题,再动手改代码。这个项目最值得投入的地方,不是功能做得多花哨,而是把一条HTTP请求从浏览器到MySQL的完整链路彻底弄明白。希望帮到你。

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

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

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

立即咨询