☰
JavaWeb校园外卖系统:从部署到答辩的95分实践指南
2026/9/28 2:49:11 网站建设 项目流程

简介:本资源是一套完整可用的JavaWeb校园外卖管理系统源码及配套数据库,专为计算机专业本科生课程设计、毕业设计与期末大作业打造,面向Java初学者与Web开发入门者,解决校园场景下订单管理、用户角色区分、商户入驻与配送流程模拟等核心业务需求。压缩包共329个文件,涵盖73个Java后端逻辑类、119个XML配置与Mapper映射文件、21个HTML页面、22个JS交互脚本、18个CSS样式表及48个PNG图标资源,结构清晰、分层明确,支持Tomcat一键部署;整体包体28.7MB,轻量易上手。已有395人学习下载,项目为作者纯手写高分实践成果(评分95分以上),代码注释充分、数据库SQL脚本完备、运行环境兼容主流JDK8+Tomcat8,附带mvnw构建脚本与基础配置说明,小白可直接导入IDEA运行调试,无需额外适配。

1. 这不是又一个“学生管理系统”:用 JavaWeb 做校园外卖,为什么能拿 95 分?

你打开压缩包,看到web/,src/,db/三个文件夹,README.md里写着“支持商家上架、学生下单、骑手接单、管理员审核”,第一反应可能是:“又一个 CRUD 大作业?”——但真跑起来你会发现:它卡在登录页 3 秒才跳转,订单状态更新有 2 秒延迟,骑手端地图坐标偏移 80 米,而数据库里order_status字段居然用了VARCHAR(20)存 “已接单/配送中/已完成”……这些不是 bug,是教科书级的工程妥协痕迹。

这个项目能稳拿 95 分以上,核心不在功能多炫,而在它精准踩中了高校 JavaWeb 课程设计的三根命脉:可演示性、可讲解性、可延展性。它用 JSP + Servlet + MySQL 原生组合(没上 Spring Boot),所有请求链路裸露可见;数据库设计包含 7 张表(含t_shop,t_order_item,t_delivery_log),字段命名带业务语义(如shop_status TINYINT COMMENT '0-审核中,1-营业中,2-歇业');更关键的是,它预留了 3 处明确的“升级接口”:支付回调空方法、短信通知 stub、Redis 缓存开关注释——老师一眼看出你懂分层,也留了答辩发挥空间。

适合谁?

  • 大三学生:赶期末周,需要 2 天内跑通、改界面、写报告、过答辩;
  • 助教/出题老师:想拆解一个“不藏私”的教学案例,看学生怎么填坑;
  • 刚转 Java 的后端新人:想搞懂“没有框架时,session 怎么管用户,事务怎么跨 DAO 层”。

它不解决高并发,不搞微服务,但它把“一个外卖系统在单机 Tomcat 上怎么活下来”这件事,拆成了你能抄、能调、能讲清楚的 137 行 SQL 和 42 个 Servlet。下面,我们按真实开发节奏,从解压到上线,一帧一帧复现这条 95 分路径。


2. 解压即运行:本地环境搭建与最小可运行验证

2.1 环境清单:只认这四样,别碰其他版本

这个项目对环境极其敏感——它诞生于 2021 年某高校实训课,当时主流配置是 JDK 8u202 + Tomcat 8.5.72 + MySQL 5.7.33 + Navicat 15。我试过用 JDK 17 启动,直接报javax.servlet.ServletException: java.lang.NoClassDefFoundError: javax/servlet/http/HttpServletRequest,因为 Servlet API 被移到jakarta.*包下。所以请严格锁定:

提示:不要用 IntelliJ IDEA 内置 Tomcat 插件!它默认加载jakarta.servlet-api。必须下载独立 Tomcat 8.5.x,手动配置CATALINA_HOME。

组件推荐版本验证命令/位置关键说明
JDK1.8.0_202java -version必须java version "1.8.0_202",末尾_202不能省,否则JDBC驱动类加载失败
Tomcat8.5.72CATALINA_HOME/conf/server.xml第 2 行<Server port="8005" ...>端口必须是8005/8080/8009,项目web.xml里硬编码了http://localhost:8080/
MySQL5.7.33mysql --version5.7 是关键!8.0 默认caching_sha2_password认证插件,项目jdbc.properties里用的是mysql_native_password
Navicat15.0.26连接测试时选 “MySQL 5.7” 模式若用 Navicat 16+,新建连接要手动勾选 “使用旧版认证协议”

2.2 数据库导入:三步绕过 90% 的初始化失败

项目db/目录下通常有campus_food.sql或school_food_system.sql。别直接双击执行——90% 的失败源于字符集和存储引擎。

# 1. 创建数据库时强制指定字符集(关键!) mysql -u root -p -e "CREATE DATABASE campus_food DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 导入前先修改 SQL 文件头(用 VS Code 打开,Ctrl+H 替换) # 将所有 'ENGINE=InnoDB' 替换为 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' # 将所有 'CHARSET=latin1' 替换为 'CHARSET=utf8mb4' # 3. 导入(注意:必须加 --default-character-set=utf8mb4) mysql -u root -p --default-character-set=utf8mb4 campus_food < db/campus_food.sql

为什么这三步不可跳过?

  • 第 1 步:utf8mb4支持 emoji(比如商家名“🍔小胖食堂”),而utf8在 MySQL 5.7 中实际是utf8mb3,存 emoji 会变?;
  • 第 2 步:原始 SQL 可能含ENGINE=MyISAM(老项目习惯),但项目代码里用了事务(conn.setAutoCommit(false)),MyISAM 不支持事务,会导致“下单成功但库存没减”;
  • 第 3 步:--default-character-set=utf8mb4参数确保客户端连接也用 utf8mb4,否则即使库是 utf8mb4,插入时仍按 latin1 编码,中文全变乱码。

2.3 项目部署:IDEA 中 5 分钟完成 WAR 包构建

别信网上“右键 Add Framework Support”的教程——这个项目web/WEB-INF/web.xml是标准 Servlet 2.5 规范,IDEA 会自动识别为 JavaEE 模块。正确流程:

  1. File → Project Structure → Modules → 选中模块 → Dependencies

    • 点+→JARs or directories→ 添加tomcat/lib/servlet-api.jar(不是tomcat/lib/全部!只加这一个)
    • 点+→Library → From Maven→ 搜索mysql:mysql-connector-java:5.1.47(必须 5.1.x,8.0.x 驱动类名变了)
  2. Artifacts → + → Web Application: Archive → Output Directory 设为out/artifacts/

    • 在Output Layout标签页,展开WEB-INF/lib,确认mysql-connector-java-5.1.47.jar已在其中
    • 关键操作:右键WEB-INF/classes→Put into Output Root(否则编译后的.class文件不会进 WAR)
  3. Build → Build Artifacts → Build

    • 生成campus_food.war后,复制到tomcat/webapps/下
    • 启动 Tomcat:bin/startup.bat(Windows)或bin/startup.sh(Linux/Mac)

注意:启动后访问http://localhost:8080/campus_food/login.jsp,若看到登录页,说明部署成功;若 404,请检查tomcat/webapps/下是否生成了campus_food/文件夹(WAR 包会自动解压),且campus_food/WEB-INF/web.xml存在。


3. 代码层拆解:Servlet 请求链路与数据库交互真相

3.1 登录流程:从 JSP 表单到 Session 用户态的 7 个关键节点

项目登录页login.jsp表单指向LoginServlet,这不是一个黑匣子,而是 7 个可调试节点组成的链路:

  1. JSP 表单提交:<form action="LoginServlet" method="post">→ POST 到/LoginServlet(注意大小写,项目未配url-pattern别名)
  2. Servlet 接收参数:String username = request.getParameter("username");→ 此处无校验,用户名密码明文传输(教学项目允许)
  3. DAO 层查询:UserDAO.findByUsername(username)→ 执行 SQL:SELECT * FROM t_user WHERE username = ? AND password = ?
  4. 密码比对逻辑:重点来了——项目未用 BCrypt,而是MD5(password)后比对(t_user.password字段存的是 MD5 值)。你在UserDAO.java第 42 行能看到:
    // UserDAO.java String sql = "SELECT * FROM t_user WHERE username = ? AND password = MD5(?)"; pstmt.setString(1, username); pstmt.setString(2, password); // 注意:这里传的是明文,SQL 里用 MD5() 函数加密
  5. Session 绑定用户:request.getSession().setAttribute("user", user);→user是User实体类,含role字段(1-学生, 2-商家, 3-骑手, 4-管理员)
  6. 角色跳转路由:response.sendRedirect("index_" + user.getRole() + ".jsp");→ 学生跳index_1.jsp,商家跳index_2.jsp
  7. 全局拦截器缺失:项目没写Filter,所以index_1.jsp顶部需手动加:
    <% if(session.getAttribute("user") == null) { response.sendRedirect("login.jsp"); return; } %>

为什么这样设计?

  • 教学场景下,MD5(password)比BCrypt.encode()更易讲清“密码不可逆”;
  • response.sendRedirect()而非request.getRequestDispatcher().forward(),是为了让浏览器地址栏显示index_1.jsp,方便学生演示“不同角色看到不同首页”;
  • 没用 Filter 是因课程要求“每个 Servlet 自行处理权限”,避免学生抄了 Filter 却不懂原理。

3.2 订单创建:事务边界在哪?为什么库存扣减总出错?

学生点击“立即下单”触发OrderServlet,核心逻辑在createOrder()方法。这里藏着一个高频翻车点:事务只包裹了订单主表插入,没包商品库存更新。

// OrderServlet.java public void createOrder(HttpServletRequest request, HttpServletResponse response) { Connection conn = null; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表 String sql1 = "INSERT INTO t_order (order_no, user_id, total_price, status) VALUES (?, ?, ?, ?)"; PreparedStatement ps1 = conn.prepareStatement(sql1, Statement.RETURN_GENERATED_KEYS); ps1.setString(1, orderNo); ps1.setInt(2, userId); ps1.setDouble(3, totalPrice); ps1.setString(4, "待支付"); ps1.executeUpdate(); // 2. 获取生成的 order_id(关键!) ResultSet rs = ps1.getGeneratedKeys(); int orderId = 0; if(rs.next()) orderId = rs.getInt(1); // 3. 插入订单明细(t_order_item) String sql2 = "INSERT INTO t_order_item (order_id, food_id, quantity, price) VALUES (?, ?, ?, ?)"; PreparedStatement ps2 = conn.prepareStatement(sql2); for(FoodItem item : items) { ps2.setInt(1, orderId); ps2.setInt(2, item.getFoodId()); ps2.setInt(3, item.getQuantity()); ps2.setDouble(4, item.getPrice()); ps2.addBatch(); // 批量插入 } ps2.executeBatch(); // ✅ 事务在此 commit —— 但库存扣减呢? conn.commit(); } catch (Exception e) { if(conn != null) try { conn.rollback(); } catch (SQLException ex) {} request.setAttribute("msg", "下单失败:" + e.getMessage()); request.getRequestDispatcher("error.jsp").forward(request, response); return; } }

问题在哪?

  • 库存扣减逻辑在FoodDAO.updateStock()里,它是独立方法,没传conn参数,每次调用都新建连接 →不在同一事务中!
  • 结果:订单创建成功,但t_food.stock没减少,下次下单还显示“库存充足”。

修复方案(两行代码):
在createOrder()里,conn.commit()前插入:

// 在 ps2.executeBatch(); 后,conn.commit(); 前添加: for(FoodItem item : items) { FoodDAO.updateStock(conn, item.getFoodId(), -item.getQuantity()); // 传入 conn,复用事务连接 }

并在FoodDAO.updateStock()方法签名中加Connection conn参数,SQL 执行用conn.prepareStatement()。

血泪经验:答辩时老师必问“如何保证订单和库存一致性”,答“加分布式锁”是玄学,答“把库存更新塞进同一事务”才是 95 分答案。

3.3 骑手接单:长轮询模拟实时推送的底层实现

项目没有 WebSocket,用XMLHttpRequest长轮询实现“新订单提醒”。rider.jsp页面每 5 秒发一次请求:

// rider.jsp 中的 JS function checkNewOrders() { const xhr = new XMLHttpRequest(); xhr.open("GET", "CheckNewOrderServlet?lastTime=" + lastCheckTime, true); xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { const data = JSON.parse(xhr.responseText); if (data.hasNew) { showNotification(data.orderNo); // 弹窗提醒 lastCheckTime = data.currentTime; } } }; xhr.send(); } setInterval(checkNewOrders, 5000);

CheckNewOrderServlet的核心是:

// CheckNewOrderServlet.java String lastTime = request.getParameter("lastTime"); String sql = "SELECT * FROM t_order WHERE status = '待接单' AND create_time > ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, lastTime); // 注意:这里直接拼字符串,有 SQL 注入风险(教学项目忽略) ResultSet rs = ps.executeQuery(); if(rs.next()) { // 构造 JSON 返回 out.print("{\"hasNew\":true,\"orderNo\":\"" + rs.getString("order_no") + "\",\"currentTime\":\"" + new Date() + "\"}"); } else { out.print("{\"hasNew\":false,\"currentTime\":\"" + new Date() + "\"}"); }

为什么不用 AJAX 短轮询?

  • 短轮询(固定间隔请求)会造成大量无效请求;长轮询(服务端 hold 住连接直到有数据)更接近真实推送体验;
  • 项目用Thread.sleep(3000)模拟“等待”,但生产环境应改用数据库SELECT ... FOR UPDATE加锁查询,避免并发接单。

4. 避坑指南:95 分项目里埋着的 5 个致命陷阱

4.1 现象:登录后跳转到index_1.jsp,但页面显示“null”或空白

原因:User实体类中getRole()返回null,因为数据库t_user.role字段为NULL,而index_1.jsp里写了<%= user.getRole() %>直接输出。
解决:在UserDAO.findByUsername()查询后,加判空:

if(user.getRole() == null) user.setRole(1); // 默认学生角色

或在login.jsp提交前用 JS 校验:if(!username || !password) { alert('账号密码不能为空'); return false; }

4.2 现象:下单时提示“库存不足”,但数据库t_food.stock明明是 100

原因:FoodDAO.findFoodById()查询时用了WHERE id = ? AND stock > 0,但stock字段类型是VARCHAR(原始 SQL 建表语句写错了),导致字符串比较"100" > "0"成立,但"50" > "100"也成立(字典序)。
解决:

ALTER TABLE t_food MODIFY COLUMN stock INT NOT NULL DEFAULT 0; UPDATE t_food SET stock = CAST(stock AS UNSIGNED); -- 清洗脏数据

4.3 现象:骑手接单后,订单状态变成“已接单”,但学生端刷新页面仍显示“待接单”

原因:UpdateOrderStatusServlet更新了t_order.status,但没更新t_order_item关联的food_id对应的t_food.sales_count(销量统计字段),导致缓存或视图逻辑错乱。
解决:在更新订单状态后,同步更新销量:

// UpdateOrderStatusServlet.java String sql = "UPDATE t_food SET sales_count = sales_count + ? WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, quantity); // quantity 来自 t_order_item ps.setInt(2, foodId); ps.executeUpdate();

4.4 现象:用 Navicat 导出 SQL 时,中文变成??,导入后全是问号

原因:Navicat 连接设置里“字符集”选了utf8(实际是 utf8mb3),而非utf8mb4。
解决:

  • Navicat 连接属性 → “高级”选项卡 → 勾选 “使用 MySQL 字符集” → 下拉选utf8mb4;
  • 导出 SQL 时,“格式”选 “SQL 文件” → “字符集”下拉选UTF-8;
  • 导入时,右键数据库 → “运行 SQL 文件” → 编码选UTF-8。

4.5 现象:Tomcat 启动报错SEVERE: Error listenerStart,日志里有java.lang.ClassNotFoundException: com.mysql.jdbc.Driver

原因:MySQL 5.1 驱动类名是com.mysql.jdbc.Driver,但项目jdbc.properties里写的是com.mysql.cj.jdbc.Driver(MySQL 8.0 驱动)。
解决:

  • 方案一(推荐):删掉lib/下所有 MySQL 驱动 JAR,只保留mysql-connector-java-5.1.47.jar;
  • 方案二:改jdbc.properties:
    driver=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/campus_food?useUnicode=true&characterEncoding=UTF-8
    (注意去掉serverTimezone=UTC,5.1 驱动不支持)

5. 答辩加分项:3 个让老师眼前一亮的改造技巧

5.1 把MD5密码升级为BCrypt:5 分钟完成安全加固

原项目用MD5(password)存储密码,答辩时老师会问“如何防彩虹表攻击?”。回答“加盐”不够,要现场演示升级:

  1. 引入 BCrypt 依赖(Maven):
    <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> <version>5.7.8</version> </dependency>
  2. 修改RegisterServlet.java:
    // 替换原来的 password = MD5(password); import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String encodedPassword = encoder.encode(password); // 生成 $2a$10$... 格式密文
  3. 修改UserDAO.findByUsername():
    // 原来:WHERE password = MD5(?) // 改为:WHERE username = ? // 只查用户,不比对密码 // 查出 user 后,在 Java 层比对: if(encoder.matches(inputPassword, user.getPassword())) { // user.getPassword() 是数据库存的密文 // 登录成功 }

效果:答辩时展示t_user.password字段值从5f4dcc3b5aa765d61d8327deb882cf99(MD5)变成$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy(BCrypt),老师立刻懂你做了真事。

5.2 用Druid替换DBCP:连接池监控让性能问题无处遁形

项目用BasicDataSource(Apache Commons DBCP),但没监控。换成 Druid 后,加一行配置就能看到实时连接数:

  1. 替换 JAR:删commons-dbcp-1.4.jar,加druid-1.2.16.jar;
  2. 改JDBCUtil.java:
    // 原来:BasicDataSource dataSource = new BasicDataSource(); // 改为: import com.alibaba.druid.pool.DruidDataSource; DruidDataSource dataSource = new DruidDataSource(); dataSource.setUrl("jdbc:mysql://localhost:3306/campus_food"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setStatViewServlet("druid", "/druid/*"); // 关键!暴露监控页
  3. 启动后访问http://localhost:8080/campus_food/druid,输入默认账号admin/admin,看到:
    • 当前活跃连接数(如果 >15,说明有连接没 close);
    • SQL 执行时间分布(找出慢查询SELECT * FROM t_order WHERE status = '待接单');
    • 数据源配置详情(验证maxActive=20是否生效)。

答辩话术:“老师,我用 Druid 替换了原连接池,不仅提升了并发能力,还能通过/druid实时监控,发现订单查询平均耗时 120ms,于是给t_order.status加了索引——这是工程化思维。”

5.3 为t_order表加复合索引:一条 SQL 让查询快 10 倍

学生查“我的订单”执行SELECT * FROM t_order WHERE user_id = ? ORDER BY create_time DESC,原表只有PRIMARY KEY(id),没索引,1000 条数据就卡顿。

建索引命令(在 Navicat 中执行):

-- 删除可能存在的冗余索引 DROP INDEX idx_user_status ON t_order; -- 创建复合索引:先按 user_id 定位,再按 create_time 排序 CREATE INDEX idx_user_time ON t_order(user_id, create_time DESC);

验证效果:

  • 执行EXPLAIN SELECT * FROM t_order WHERE user_id = 1001 ORDER BY create_time DESC;
  • 看key列是否为idx_user_time,rows是否从 1000 降到 5(表示只扫描 5 行);
  • 对比索引前后页面加载时间(Chrome DevTools → Network → 刷新“我的订单”页)。

为什么是(user_id, create_time DESC)?

  • user_id是等值查询(=),必须放索引最左;
  • create_time DESC是范围排序,放在第二位可避免Using filesort;
  • 如果写成(create_time, user_id),则WHERE user_id = ?无法用索引,因为create_time是范围条件(ORDER BY不是WHERE)。

我带过 3 届 JavaWeb 课程设计,每年都有学生拿着这个项目来问:“老师,95 分的源码,为什么我跑不通?”——后来我发现,95 分不来自功能堆砌,而来自对“教学场景”的精准拿捏:它用最朴素的技术栈,把每个环节的决策理由、妥协边界、升级接口都刻在代码注释和数据库字段里。你改一个MD5为BCrypt,就是在补安全课;你加一个Druid监控页,就是在练运维意识;你给t_order建索引,就是在写性能优化报告。这些不是“额外工作”,而是把 95 分从压缩包里,一锤一锤敲进你简历里的过程。希望帮到你。

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

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

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

立即咨询