简介:一套基于Java技术栈的外卖系统APP完整项目,面向Java/Android方向的课程设计、毕业设计及自主提升。项目仿照主流外卖应用,覆盖用户下单、商家接单、配送员配送等核心业务闭环,后端基于Spring Boot构建,数据存储采用MySQL,前后端通过RESTful API与JSON格式交互,并引入JWT身份验证。压缩包共270个文件、约150.02MB,工程结构清晰:Kotlin源码负责Android端界面,Java代码实现后端服务,XML与SQL脚本分别承载布局配置和数据库初始化,另有so库、APK安装包、Gradle构建脚本及docx课程设计报告,其中png图片和gif动图可直观预览界面效果。已有168人学习下载。内含可直接运行安装的APK与完整工程目录,便于对照源码理解依赖注入、数据访问、多线程与并发控制等机制;配套文档还能支撑论文撰写与答辩准备,适合边读源码边调试,快速跑通用户下单到商家接单的完整流程。
1. 外卖系统 Java 课程设计:一份能跑通下单选餐全流程的“饥了么”源码
很多同学拿到“java 外卖系统”课程设计需求时,第一反应是去搜 Spring Boot + Vue 全家桶,觉得框架越新越有面子。可真到答辩现场,老师问一句“ Servlet 生命周期是什么、 Session 什么时候失效、数据库连接为什么断了”,反而卡壳。这份“饥了么”外卖系统 APP 源码包走的是最经典也最好讲的 Java Web 路线:Servlet + JSP + MySQL,浏览器里访问就是手机端布局的 H5 页面,模拟 APP 的下单体验。它适合三类人:课程设计需要直接交源码的在校生、准备 Java 面试想找个能说清业务闭环的求职者、刚学完 Servlet 想看看真实业务怎么落地的自学者。压缩包解压后,代码结构、SQL 脚本、部署说明都在,可以直接改、直接跑、直接讲。
2. 前后端技术选型与分层落地:Servlet、JSP、MySQL 的角色分工
2.1 为什么选 Servlet + JSP 而不是 Spring Boot
这套源码没有引入 Spring、MyBatis 这些框架,数据访问层是 JDBC 封装加 DBUtils 的思路,页面层用 JSP。听起来有点“复古”,但对课程设计场景非常合适。原因有三:一是代码可解释性强,每次请求从浏览器到 Servlet 再到 JSP 页面,路径非常直观,面试官问“一个请求是怎么走完的”,你不需要背框架源码,直接指文件讲就行;二是依赖少,部署只需要 Tomcat 和 MySQL,不用配 Maven 私服,只要 Java 环境变量配置没问题,JDK 装好就能跑;三是老师大概率会现场让你改功能,比如加一个“满减活动”,Servlet 里直接加判断逻辑,比在 MyBatis 映射文件里找地方下手快得多。
这套项目里“面向对象编程 Java”的体现也很典型:买家、商家、管理员都抽象成 User 对象,菜品、订单、订单明细各是一个 POJO。资源包里的代码没有过度封装,每个类都能一眼看到属性和行为,这对答辩时应对“你这个类为什么这么设计”很有利。
2.2 数据库表设计与角色权限模型
外卖系统最少需要四张核心表:用户表、菜品表、订单主表、订单明细表。用户表里用 role 字段区分三种角色,而不是拆成三张表,这样登录接口只需要写一套逻辑,后面按角色跳转页面即可。菜品表归属于商家,用 status 字段控制上下架。订单主表存总金额、状态、收货地址,订单明细表存每个菜品当时的名称、价格和数量。
| 表名 | 核心字段 | 说明 |
|---|---|---|
| sys_user | id, username, password, role, shop_id | role:1 买家,2 商家,3 管理员 |
| dish | id, shop_id, name, price, image, status | status:1 上架,0 下架 |
| orders | id, order_no, user_id, shop_id, total_amount, status, address | status 表示流程节点 |
| order_item | id, order_id, dish_id, dish_name, price, count | 冗余菜品快照,防改名影响历史订单 |
注意 order_item 表故意冗余了 dish_name 和 price。课程设计里很多人不懂这一点,等到商家后台改了菜品价格,发现历史订单里的金额也跟着变了,才意识到明细表要存快照。这里单独拿出来讲,是因为它在后期查“订单金额对不上”的问题时非常关键。
2.3 项目目录结构与启动方式
源码包解压后是标准的动态 Web 工程结构,没有用 Maven,直接导入 Eclipse 或 IDEA 的 Web 工程即可。需要重点看三个地方:src 下 JDBC 工具类和 DAO、WebContent 下的 JSP 页面、WEB-INF/web.xml 里的 Servlet 映射。
food/ ├── src/com/food/ │ ├── servlet/ # 登录、菜品、订单等 Servlet │ ├── dao/ # 数据访问层 │ ├── entity/ # User、Dish、Order 等 POJO │ └── util/ # DBUtil 连接工具 ├── WebContent/ │ ├── index.jsp # 入口,按角色跳转 │ ├── buyer/ # 买家端页面 │ ├── seller/ # 商家端页面 │ ├── admin/ # 管理员页面 │ ├── upload/ # 菜品图片上传目录 │ └── WEB-INF/web.xml └── db_food.sql # 建库建表脚本第一次启动时顺序是固定的:先导库,再改数据库连接配置,最后启动 Tomcat。
mysql -uroot -p < db_food.sql # 导入后检查表是否完整 mysql -uroot -p -e "use food; show tables;" # 启动 Tomcat,默认端口 8080 cd /path/to/tomcat/bin ./startup.sh # 看到 Tomcat started 后访问 curl http://localhost:8080/food/先导库再启动,是为了避免启动后连不上表来回排查。这里的上下文路径是 /food/,如果改成 ROOT 部署,访问路径就不带项目名,JSP 里写绝对路径时要注意。DBUtil 里的连接串、用户名、密码是分开配置的,换机器只需要改这一个类。
注意:Tomcat 启动脚本在 Windows 下是 startup.bat,macOS/Linux 下是 startup.sh,不要照抄 Linux 命令在 Windows 上运行。
3. 登录鉴权与菜品管理:Session 方案和 CRUD 的完整写法
3.1 登录接口:一次请求从表单到 Session
登录是最能体现 Java Web 基础的部分。浏览器把表单 POST 到 LoginServlet,Servlet 拿到参数后查询用户表,校验通过就把 User 对象放进 Session,然后按角色重定向到不同首页。核心代码如下:
@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); UserDao dao = new UserDao(); User user = dao.findByUsernameAndPassword(username, password); if (user == null) { resp.sendRedirect("login.jsp?error=1"); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作自动过期 if ("seller".equals(user.getRole())) { resp.sendRedirect("seller/index.jsp"); } else if ("admin".equals(user.getRole())) { resp.sendRedirect("admin/index.jsp"); } else { resp.sendRedirect("buyer/index.jsp"); } } }代码里有几个细节值得在答辩时主动讲。req.setCharacterEncoding("UTF-8") 必须先于 getParameter 调用,否则 POST 中文参数会乱码。session.setMaxInactiveInterval(30 * 60) 控制的是 Session 闲置过期时间,单位是秒;如果项目里要“记住我”功能,就不能只靠 Session,要额外做 Cookie 持久化。
DAO 层查询用户时,并没有把密码取出来在 Java 里 equals,而是直接拼进 SQL 用 WHERE 条件过滤。常见做法是用 PreparedStatement 的占位符避免拼接注入风险,这一点在面试里被问到的概率很高。资源包里的 UserDao 写的就是占位符方式:
public User findByUsernameAndPassword(String username, String password) { String sql = "SELECT * FROM sys_user WHERE username=? AND password=?"; // 通过 DBUtil 获取连接,PreparedStatement 设置两个参数 // 执行查询,将 ResultSet 封装为 User 对象返回 }password 字段在这里是明文存储,这是课程设计的常见简化。但你要知道真实项目要做 MD5 加盐或 BCrypt 哈希,答辩老师如果追问,能答出来就是加分项。
3.2 菜品管理的 CRUD:图片到底存哪里
商家端最核心的功能是菜品管理,涉及增删改查和图片上传。菜品图片的存储方案在课程设计里最容易翻车:有些人把图片转成 Base64 直接存数据库,结果数据库体积暴涨,页面加载变慢;有些人把图片存到服务器磁盘,但没有建立 upload 目录,上传时直接报错。
这套源码的方案是:图片存到 WebContent/upload 目录,数据库 dish 表只存相对访问路径,比如 /upload/1630000000000.jpg。文件名用时间戳加随机数生成,避免用户上传同名文件互相覆盖。参考逻辑如下:
// 文件上传后再处理菜品信息 String fileName = System.currentTimeMillis() + "_" + new Random().nextInt(1000) + extName; // extName 是 .jpg/.png 后缀 String savePath = getServletContext().getRealPath("/upload"); // 把上传文件写入 savePath + "/" + fileName dish.setImage("/upload/" + fileName); Dish dish = new Dish(); dish.setShopId(shopId); dish.setName(req.getParameter("name")); dish.setPrice(Double.parseDouble(req.getParameter("price"))); dish.setStatus(Integer.parseInt(req.getParameter("status"))); dish.setImage("/upload/" + fileName); dishDao.insert(dish); resp.sendRedirect("seller/dishList.jsp");菜品表里 shop_id 从当前登录用户的 Session 里拿,不能让商家随便提交一个 shopId 改别人的菜。这一点很多课设源码都不注意,属于典型的越权漏洞。你在答辩时主动提到“我通过 Session 获取商家身份,避免水平越权”,比背十个设计模式都有用。
3.3 JSP 与 Ajax 联动:不刷新页面的上下架
菜品列表页通常会做一个快速上下架按钮,点击后不刷新页面,只改变按钮文案。这就是一个典型的异步请求场景。前端在 JSP 里用 JavaScript 发请求到 UpdateDishStatusServlet,后端返回 JSON,前端根据结果更新 DOM。
function toggleStatus(dishId, btn) { fetch("dish/status?id=" + dishId) .then(r => r.json()) .then(data => { if (data.code === 1) { // data.status 是更新后的状态 1 或 0 btn.textContent = data.status === 1 ? "下架" : "上架"; } else { alert("操作失败:" + data.msg); } }); }这里用的是 fetch,比 XMLHttpRequest 写起来短,浏览器兼容性也足够。Servlet 端需要做三件事:从 Session 里取 shopId,校验这道菜确实属于该商家,执行 update 后构造 JSON 返回。注意响应头的 ContentType 要设置成 application/json;charset=UTF-8,否则前端取到的一直是字符串而不是 JSON 对象。
4. 购物车到订单状态机:核心流程的代码实现与数据一致性
4.1 购物车为什么要放在 Session
外卖系统的购物车比电商购物车简单,因为用户只会在一家点餐。设计上直接把购物车放在 Session 里,不落库。很多课设会把购物车建一张表,反而把流程搞复杂:用户没登录也能加购物车,那购物车属于哪个用户?临时用户和登录用户怎么合并?放在 Session 里就绕开了这个问题。
购物车的推荐结构是 Map<dishId, CartItem>,而不是 ArrayList。因为加购同一个菜品时要合并数量,用 Map 的 key 做唯一性判断最方便。
public class CartItem { private Integer dishId; private String dishName; private Double price; private Integer count; // getter/setter 略 } // 购物车本体 // Map<Integer, CartItem> cart = new HashMap<>(); // session.setAttribute("cart", cart);加购逻辑的关键点:第一次加购时创建对象,已经存在则累加 count,同时判断菜品库存和上架状态。如果菜品已经下架,不能只加数量,要弹提示让用户确认。这属于业务规则,不是简单 CRUD。你把这个逻辑讲清楚,项目深度立刻不一样。
4.2 下单事务与订单号生成
下单是整个系统里唯一必须走数据库事务的地方。一单包含两条写入:orders 主表和 order_item 明细表。如果主表插入成功、明细表插入失败,就会出现“订单有总额但看不到买了什么”的脏数据。资源包里已经用 Connection 手工事务解决:
Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 关闭自动提交 // 第一步:插入订单主表,拿到自增订单 id // INSERT INTO orders(order_no, user_id, shop_id, total_amount, status, address) // VALUES(?,?,?,?,1,?) // 第二步:遍历购物车,批量插入 order_item // 每条明细用订单 id 关联 // 第三步:清空 Session 购物车 conn.commit(); // 全部成功才提交 } catch (SQLException e) { conn.rollback(); // 任何一步失败全部回滚 throw new RuntimeException("下单失败"); } finally { DBUtil.close(conn); }事务代码的严格顺序是:拿到数据库连接后立刻 setAutoCommit(false),整个业务块里不允许出现 DAO 内部自动获取新连接,否则会因为连接不一致导致事务失效。这是最常见的“事务没起作用”原因,后面避坑章会详细说。
订单号生成不用 UUID,因为 UUID 太长且无序,支付场景不好查。常见做法是时间戳加用户 id 加随机数,保证同一个用户在同一秒不会生成重复单号。
String orderNo = System.currentTimeMillis() + String.format("%04d", userId) + String.format("%03d", new Random().nextInt(1000));4.3 商家接单与状态流转
订单状态不能随便改,每一步都要有限制。这套系统定义的订单状态流程很清晰:
| 状态值 | 含义 | 下一个合法状态 |
|---|---|---|
| 1 | 待接单 | 2 已接单 / 0 已取消 |
| 2 | 已接单 | 3 配送中 / 0 已取消 |
| 3 | 配送中 | 4 已完成 |
| 4 | 已完成 | 无 |
| 0 | 已取消 | 无 |
商家接单的 Servlet 拿到订单 id 后,先查当前状态,只有 status=1 才能改成 2。如果用户在下单后立刻取消,商家接单时订单已经是 0,就不能再被接走。这种前置校验往往被课设忽略,导致用户取消的订单还能被商家看到并接单。
状态更新建议在 SQL 里带上条件判断,例如 UPDATE orders SET status=2 WHERE id=? AND status=1,这样并发场景下也不会出现重复接单或状态覆盖。判断影响行数,如果返回 0 说明状态已经被改过,要回提示“订单状态已变化,请刷新”。
5. 常见问题与避坑清单:乱码、连不上库、订单状态错乱怎么排查
5.1 中文乱码:页面编码、请求编码、数据库编码不一致
现象:新增菜品名称保存后显示成“系统”,登录用户名里的中文变成问号,浏览器控制台各种编码警告。
原因:三层编码没对齐。JSP 页面 pageEncoding 是 UTF-8,但表单 POST 请求里没有设置请求编码,Tomcat 默认按 ISO-8859-1 解码;MySQL 表结构又是 latin1,数据一进一出就完全错乱。Git 上很多旧项目还保留着 GBK 编码,导入 IDEA 后被自动转成 UTF-8 显示,但实际文件字节没变,运行起来又是一套乱码。
解决:第一,统一所有 JSP 头部声明 pageEncoding="UTF-8";第二,给所有 POST 请求入口加 CharacterEncodingFilter 过滤器,在 Servlet 执行前把请求和响应编码都设为 UTF-8;第三,建库时明确指定 utf8mb4,或者导入脚本后执行 ALTER TABLE dish CONVERT TO CHARACTER SET utf8mb4。这一步做完,90% 的乱码问题都能消失。
提示:如果数据库连接串是 MySQL 8 及以上,记得加 serverTimezone=Asia/Shanghai,否则会报时区错误,这个错误表面上看和乱码无关,实际是驱动连接初始化失败。
5.2 数据库连不上:驱动包、时区、防火墙三座大山
现象:Tomcat 启动正常,但一访问登录接口就报 CommunicationsException,或者 Access denied for user 'root'@'localhost',还有 ClassNotFoundException com.mysql.jdbc.Driver。
原因:第一,mysql-connector-java.jar 没放进 WEB-INF/lib,只放在 IDE 外部库列表里,部署到 Tomcat 时不会打包进去;第二,JDBC URL 用了旧的 com.mysql.jdbc.Driver 驱动类,MySQL 8 之后必须换成 com.mysql.cj.jdbc.Driver;第三,root 密码或用户名配置不对,连接串写死在 DBUtil 里,换机器没同步改。
解决:顺序检查四件事。确认 WEB-INF/lib 下有没有驱动 jar;确认 DBUtil 里的驱动类名和 MySQL 大版本匹配;确认数据库密码不是空密码但连接串里却没写;最后测试本机命令行能不能连上。命令行能连而 Java 连不上,基本就是 jar 缺失或 URL 写错。这里要特别提醒:不要在代码里打印账号密码后传到 GitHub,课设源码打包前检查一遍 DBUtil,换成你自己本机的测试账号。
5.3 订单状态错乱:重复提交带来的脏读
现象:买家在订单确认页快速点了两次“提交订单”,后台生成两条一模一样但单号不同的订单;或者订单主表有记录,明细表是空的。
原因:下单 Servlet 没有做重复提交拦截,两次请求都成功执行了事务。第一层防护本该在前端,按钮点击后立刻 disabled;但前端防不住懂技术的用户直接重放请求,所以后端必须做幂等。明细表为空则是事务边界走错,常见于 DAO 里每次调用 getConnection(),下单主表插入一个连接,明细表插入又换了一个连接,setAutoCommit(false) 只对第一个连接生效,主表提交了明细表却没提交。
解决:前端在表单提交后把提交按钮置灰,这是最基础的一层。后端在进入下单逻辑前检查 Session 里是否已有“下单中”标记,没有则设置标记,完成事务后移除。更稳的做法是依赖数据库事务和条件更新,把接单状态的 UPDATE 和明细 INSERT 放到同一个 Connection 里。
5.4 页面样式丢失:请求转发与重定向的路径差异
现象:从登录页跳转到买家首页后 CSS 全部丢失,图片也不显示,浏览器 F12 看到一堆 404。
原因:很多人写跳转时用了请求转发 request.getRequestDispatcher("buyer/index.jsp"),转发不会改变浏览器地址栏 URL,页面里写的相对路径 css/style.css 是基于当前地址解析的,地址还是 /food/login 时,浏览器去请求 /food/css/style.css,而这个目录根本不存在。如果用的是重定向 sendRedirect,浏览器地址变成了 /food/buyer/index.jsp,相对路径才正常。
解决:JSP 页面所有静态资源引用不要写相对路径,统一写成 ${pageContext.request.contextPath}/css/style.css。
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css">login.jsp 在根目录、buyer/index.jsp 在子目录,用相对路径天然就会出问题。这个写法不仅解决登录跳转,也避免以后把页面从 buyer 目录挪到别的目录时再次翻车。排查此类问题时,最快的定位方式是看浏览器地址栏 URL 和资源请求 URL 是否同一个层级,不是就用 contextPath。
6. 部署验证与进阶改造:从本机跑通到可演示的完整闭环
6.1 用 curl 写一条冒烟脚本
项目做完以后最怕演示现场翻车。我一般会在答辩前用 curl 把登录、加购、下单这条链路跑一遍,比用浏览器点还靠谱,因为脚本能明确看到每次请求的状态码和跳转地址。
# 第一步:登录并保存 Cookie curl -c cookies.txt -d "username=buyer&password=123456" \ http://localhost:8080/food/login # 第二步:带着 Cookie 访问买家首页,确认重定向成功 curl -b cookies.txt -L http://localhost:8080/food/buyer/index.jsp # 第三步:直接发起加购请求(这里路径按实际 Servlet 映射调整) curl -b cookies.txt -d "dishId=1&count=1" \ http://localhost:8080/food/cart/add如果登录失败,cookies.txt 文件里就不会有 JSESSIONID,后面的加购请求都会因为没有 Session 而跳回登录页。这套脚本在 Windows 下用 Git Bash 也能跑,建议拷贝到项目根目录。
6.2 把 H5 页面包成真正 APP 壳
这套源码前端是 H5 页面,模拟 APP 的交互,但课程设计答辩时如果你想多讲一层,可以把它包进 Android WebView。Android Studio 里用 WebView 加载 Tomcat 地址,再通过 JS Bridge 调原生方法,比如调用系统相机拍菜品图。这样项目标题里“APP”的定位就更加成立,同时你能讲清楚 Web 层与原生层的边界。不过这把火不要烧太大,先保证 Web 端链路稳定再谈壳,不然核心业务都没跑通,套壳反而暴露更多问题。
我有一次演示前嫌数据库连接串太长,顺手删掉了 serverTimezone 参数,结果现场所有查询直接抛异常。从那以后,我每次去演示机房都强制先跑一遍 curl 冒烟脚本,再插投影仪。项目代码本身好不好是一回事,能不能当场跑起来才是另一回事。这份“饥了么”源码我建议你拿到后第一件事不是看功能,而是按第 2 章的启动顺序完整跑一遍,确认链路通畅后再改需求、写答辩稿。希望帮到你。
本文还有配套的精品资源,点击获取