简介:这套Java毕业设计外卖点餐系统采用Spring Boot与Vue前后端分离架构,集成Vant移动端组件与Element UI管理端组件,适合计算机相关专业学生用于毕业设计、课程实践,也适合初级开发者学习主流技术栈整合与项目工程化组织方式。资源共282个文件,核心包括Java后端源码、Vue前端页面、XML配置文件、JS脚本与SQL数据库脚本,另配有png、jpg等界面图片与markdown说明文档,压缩包整体仅14.17MB,结构清晰便于按需取用。目前已有5238人学习下载,属于轻量且热度较高的项目素材。获取后可得到完整的前后端工程骨架、数据库初始化脚本、启动运行配置文件以及界面预览素材,订单服务等业务实现类展示了分层开发思路,配合演示动图与目录说明,能有效支撑从环境搭建、功能调试到毕业设计文档撰写的全流程。该项目既适合直接二次开发,也适合作为学习主流Java Web技术栈的入门范本。
1. 一个 Spring Boot + Vue 外卖点餐项目,值得关注的不是页面而是 Service 层
如果你拿到这个压缩包先点开OrderServiceImpl.java和ShopServiceImpl.java,而不是急着看index.html,说明你已经抓住了这类全栈项目的命门:页面只是表象,服务层的职责划分和订单状态流转才是答辩时能被追问十分钟的地方。这是一个典型的三端闭环——用户端用 Vant 组件做点餐页、运营端用 Element UI 做管理页、Spring Boot 后端统一出接口,数据围绕商家、菜品、订单三张主表展开。适合两类人:准备毕业设计、想把“功能完整”讲成“设计正确”的学生;以及想借一套全栈脚手架快速改造成预约、排队、商城系统的在职开发者。下面按数据模型、后端服务、前端协作、本地联调的顺序拆开讲。
2. 数据模型与工程骨架:Shop、Dish、Orders 如何支撑双前端
2.1 前后端分离,分离的到底是什么
前后端分离表面上拆的是工程,实际上拆的是“接口契约”。Vant 移动端和 Element UI 管理端共用同一套 Spring Boot 接口,后端只要保证 URL 和 JSON 结构不变,前端路由怎么跳、按钮怎么排都不影响核心流程。这个项目的压缩包里既有index.html和favicon.ico,也有mvnw.cmd和maven-wrapper.jar,说明演示版本把前端构建产物放进了后端发布目录,开发时则走 dev server 与 8080 端口分开联调。
看后端源码的组织方式,典型结构应该是这样:
com.example.takeout ├── controller ├── service │ ├── impl │ │ ├── OrderServiceImpl.java │ │ └── ShopServiceImpl.java ├── mapper ├── entity └── dtoController 层做参数接收和结果包装,真正的业务逻辑落在service下的两个实现类里。这个分层在毕业设计里不是可选项而是必选项,因为一旦把金额计算、状态判断写在 Controller 里,后续加一个微信小程序端就要重写一套逻辑,而微软的 Service 层方案可以直接复用。
2.2 商家、菜品、订单三张表的设计要点
外卖系统最核心的三张主表是shop(商家)、dish(菜品)、orders(订单),另外还需要一张order_item存订单明细。建表语句的常见版本如下:
CREATE TABLE `shop` ( `shop_id` bigint NOT NULL AUTO_INCREMENT, `shop_name` varchar(64) NOT NULL, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1营业 0打烊', PRIMARY KEY (`shop_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `dish` ( `dish_id` bigint NOT NULL AUTO_INCREMENT, `shop_id` bigint NOT NULL, `dish_name` varchar(64) NOT NULL, `price` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`dish_id`), KEY `idx_shop` (`shop_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;订单主表需要同时挂用户和商家,并保存订单总金额和状态:
CREATE TABLE `orders` ( `order_id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL COMMENT '下单用户ID', `shop_id` bigint NOT NULL COMMENT '商家ID', `total_amount` decimal(10,2) NOT NULL, `remark` varchar(255) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2制作中 3配送中 4已完成 -1已取消', `create_time` datetime NOT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`order_id`), KEY `idx_shop_status` (`shop_id`, `status`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;明细表里有两个字段值得注意:dish_name和price是从菜品表冗余过来的。用户在 12:00 点的菜,商家在 13:00 改价,订单里记录的仍是下单那一刻的价格和名称,这是电商订单系统的常规做法。
CREATE TABLE `order_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `dish_id` bigint NOT NULL, `dish_name` varchar(64) NOT NULL COMMENT '冗余菜品名', `price` decimal(10,2) NOT NULL COMMENT '下单时价格快照', `num` int NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;orders表刻意用数值型status而不是字符串,好处是代码里比较状态只是order.getStatus() == 1,建表注释里写清含义就不会混淆。shop与dish是一对多,orders与order_item也是一对多,两张从表都通过外键字段加索引,查询链路不会被全表扫描拖慢。
2.3 Maven Wrapper 是项目里最容易忽略的基础设施
压缩包里的mvnw.cmd和maven-wrapper.jar解决的是团队协作的 Maven 版本一致性问题。只要本地有 JDK,不需要预装 Maven,直接执行:
cd backend mvnw.cmd spring-boot:runmacOS 或 Linux 下执行./mvnw spring-boot:run。Wrapper 会读取.mvn/wrapper/maven-wrapper.properties指定的 Maven 版本,自动下载到本地仓库再运行,避免“我本地能跑、你本地报错”的经典问题。
提示:如果启动时提示 JDK 版本不匹配,先看
pom.xml里的java.version是 8 还是 17,再调整本机 JDK,而不是盲目升级 Spring Boot 版本。
3. 订单与商铺的服务实现:事务边界、状态机与库存扣减
3.1 OrderServiceImpl:一次下单背后的完整链路
OrderServiceImpl是这个项目里最值得读的一个类,因为下单不是简单往表里插一条记录,而是“校验商家状态 → 后端重算金额 → 落订单主表 → 落明细表”四步动作。常见实现是:
@Service @RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final ShopMapper shopMapper; private final DishMapper dishMapper; @Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 校验商家是否营业 Shop shop = shopMapper.selectById(dto.getShopId()); if (shop == null || shop.getStatus() != 1) { throw new BizException("店铺不存在或已打烊"); } // 2. 后端重新计算金额,前端传的金额只作展示 BigDecimal total = BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { Dish dish = dishMapper.selectById(item.getDishId()); total = total.add(dish.getPrice() .multiply(BigDecimal.valueOf(item.getNum()))); } // 3. 写入订单主表,状态为待支付 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setShopId(dto.getShopId()); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 4. 批量写入订单明细 for (OrderItemDTO item : dto.getItems()) { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getOrderId()); orderItem.setDishId(item.getDishId()); orderItem.setDishName(dishMapper.selectById(item.getDishId()).getDishName()); orderItem.setPrice(dishMapper.selectById(item.getDishId()).getPrice()); orderItem.setNum(item.getNum()); orderItemMapper.insert(orderItem); } return convertToVO(order); } }这段逻辑有三个关键点:第一,金额必须在后端重算,不能让前端传总价,否则用户改请求体就能零元购;第二,@Transactional(rollbackFor = Exception.class)保证四步操作要么全部成功、要么全部回滚,不会出现“主表有订单、明细表为空”的脏数据;第三,商家状态校验前置,打烊状态下直接抛业务异常。
这里有个可以优化的点:dishName和price在第 4 步中各自查了一次菜表,循环里如果点了 10 个菜就要查 10 次。常见做法是先把菜品按 ID 批量查出来:
Map<Long, Dish> dishMap = dishMapper.selectBatchIds( dto.getItems().stream().map(OrderItemDTO::getDishId).toList() ).stream().collect(Collectors.toMap(Dish::getDishId, d -> d));这样一次 SQL 拿到全部菜品,再循环组装明细,接口响应时间在菜品种类多时能明显下降。
3.2 订单状态机:数值状态与流转边界
订单状态是这个项目另一个高频考查点。一般流程是:
| status 值 | 含义 | 触发动作 | 代码位置 |
|---|---|---|---|
| 0 | 待支付 | 用户提交订单 | createOrder |
| 1 | 已支付 | 模拟支付回调 | payOrder |
| 2 | 制作中 | 商家接单 | acceptOrder |
| 3 | 配送中 | 商家/骑手发货 | deliverOrder |
| 4 | 已完成 | 用户确认收货 | completeOrder |
| -1 | 已取消 | 超时或用户取消 | cancelOrder |
实现状态流转时常见错误是直接用不相邻的状态硬改,例如从“待支付”直接跳到“已完成”。严谨做法是在更新 SQL 里加状态条件:
UPDATE orders SET status = #{newStatus}, update_time = NOW() WHERE order_id = #{orderId} AND status = #{expectedStatus}expectedStatus在代码里是上一个状态。这个 UPDATE 影响行数为 0 时,说明当前状态不是预期值,直接抛异常。这种“乐观状态更新”不引入额外锁,也保证了状态机的顺序合法,比先查询再判断更优雅。
3.3 ShopServiceImpl:菜单组装与营业状态过滤
ShopServiceImpl主要解决“用户进店看到什么”的问题,它要把店铺信息和菜单一次性组装好给前端:
@Service public class ShopServiceImpl implements ShopService { private final ShopMapper shopMapper; private final DishMapper dishMapper; @Override public ShopDetailVO getShopDetail(Long shopId) { Shop shop = shopMapper.selectById(shopId); if (shop == null) { throw new BizException("店铺不存在"); } List<Dish> dishList = dishMapper.selectByShopIdAndStatus(shopId, 1); ShopDetailVO vo = new ShopDetailVO(); BeanUtils.copyProperties(shop, vo); vo.setDishList(dishList); return vo; } }dishMapper.selectByShopIdAndStatus(shopId, 1)中的第二个参数固定传 1,也就是说只查询上架菜品。这里把“上架/下架”的过滤逻辑放进 SQL 而不是 Java,既减少了传输数据量,也避免了下架菜品出现在点餐页。BeanUtils.copyProperties做属性拷贝是 Spring 的常规用法,字段少的场景下完全够用。
如果要在店铺列表页显示“月售 xx 单”,通常做法是在 SQL 里聚合订单明细:
SELECT shop_id, COUNT(*) AS month_sales FROM orders WHERE create_time >= DATE_FORMAT(NOW(), '%Y-%m-01') AND status IN (1, 2, 3, 4) GROUP BY shop_id注意这里过滤掉了status = 0(待支付)和status = -1(已取消),因为只有真实进入交易流程的订单才计入销量。这个细节可以在答辩时作为“业务完整性”的佐证。
3.4 库存扣减:用一条 SQL 解决超卖
外卖不比秒杀,但仍然存在“最后一份菜品被两个人同时下单”的可能。常见的错误做法是先查询库存,再判断是否大于 0,最后 UPDATE,这在高并发时会超卖。推荐做法是条件更新:
UPDATE dish SET stock = stock - #{num} WHERE dish_id = #{dishId} AND stock >= #{num}代码里判断UPDATE返回的影响行数,是 1 则扣减成功,是 0 则库存不足,直接提示用户“菜品已售罄”。这一条 SQL 完成了“检查库存 + 扣减”的原子操作,不需要分布式锁,是毕业设计里性价比最高的并发优化手段。
4. Vant 点餐端与 Element UI 管理端:一套接口两种页面
4.1 Vant 移动端点餐页:列表、SKU 与购物车
Vant 是面向移动端的 Vue 组件库,点餐页最核心的交互是“菜品列表 + 加入购物车”。常见布局是:
<template> <div class="dish-list"> <van-card v-for="dish in dishList" :key="dish.dishId" :title="dish.dishName" :price="dish.price" > <template #footer> <van-stepper :model-value="cart[dish.dishId] || 0" @change="(val) => updateCart(dish, val)" /> </template> </van-card> </div> </template>van-stepper是数量步进器,cart对象以dishId为键、数量为值。购物车本质上不需要后端参与,前端组件维护一个内存对象即可,提交订单时把cart展开成items数组传给后端。
移动端接口调用建议单独封装到一个api模块里:
// src/api/order.js import request from '@/utils/request' export function createOrder(data) { return request.post('/order/create', data) }这里只暴露了createOrder一个函数,页面里写import { createOrder } from '@/api/order'即可。把接口地址收敛到一个文件里,后端路径改了只改这一处,比在组件里散落axios.post可维护得多。
4.2 Element UI 管理端:订单表格与状态筛选
管理端面向运营人员,核心诉求是把订单列清楚、能改状态。el-table是最常用的表格组件:
<template> <div class="order-manage"> <el-table :data="orderList" border stripe> <el-table-column prop="orderNo" label="订单号" width="180" /> <el-table-column prop="totalAmount" label="金额" /> <el-table-column label="状态"> <template slot-scope="{ row }"> <el-tag :type="statusTypeMap[row.status]"> {{ statusTextMap[row.status] }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="200"> <template slot-scope="{ row }"> <el-button size="mini" type="primary" :disabled="row.status !== 1" @click="handleDeliver(row)" > 发货 </el-button> </template> </el-table-column> </el-table> </div> </template>管理端通过statusTextMap把数值状态映射为中文,statusTypeMap映射为 Element UI 的标签颜色。按钮里的:disabled="row.status !== 1"是前置条件控制,只有已支付订单才允许发货,和 Spring Boot 后端的乐观状态更新形成前后双重校验。
4.3 登录与鉴权的统一封装
这个项目涉及两种角色,骑行移动端是普通用户,管理端是运营人员,token 是区分身份的主要手段。前端拦截器统一做两件事:请求带上 token、响应 401 时跳登录。常用写法:
// src/utils/request.js import axios from 'axios' import { Toast } from 'vant' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { router.push('/login') } else { Toast.fail('请求失败,请稍后重试') } return Promise.reject(error) } ) export default service这段代码里,baseURL配成/api而不是完整地址,是为了配合开发环境的代理转发;Bearer token是常见的 Authorization 格式,后端用拦截器统一解析。把拦截器写在utils/request.js里,移动端和管理端各自引用即可。
5. 本地联调:JDK、Maven Wrapper、MySQL 与常见报错
5.1 环境确认与版本影响
开始之前先确认本机环境:
java -version node -v npm -v mysql --version后端是 Spring Boot 工程,JDK 8 或 11 足以应付绝大多数毕业设计;如果pom.xml里是 Spring Boot 3.x,则需要 JDK 17。前端 Vue 项目的 node 版本建议不低于 16,太低会装不上最新依赖。
5.2 数据库初始化与数据源配置
用 Navicat 或命令行创建数据库,并执行项目的 SQL 脚本:
mysql -uroot -p CREATE DATABASE takeout DEFAULT CHARACTER SET utf8mb4; USE takeout; SOURCE /path/to/takeout.sql;utf8mb4必须指定,否则 emoji 表情和生僻字会报编码错误。接着改后端的数据源配置:
spring: datasource: url: jdbc:mysql://localhost:3306/takeout?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone=Asia/Shanghai不能省,不然 Java 和 MySQL 的时间会差 8 小时,订单创建时间看起来像在下班后。JDK 8 项目如果用的旧驱动,driver-class-name需要写成com.mysql.jdbc.Driver,新版驱动cj结尾是标配。
5.3 启动顺序与跨域/代理配置
先启动后端,再启动前端:
cd backend mvnw.cmd spring-boot:run前端另开一个终端:
cd frontend-mobile npm install npm run dev开发环境最大的坑是跨域。前端跑在 5173,后端在 8080,直接请求会被 CORS 拦截。常见做法是前端配本地代理,Vite 项目的配置:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })原理是:浏览器只请求了前端 dev server 的/api路径,dev server 再把请求转发给 8080,浏览器看不到跨域请求,自然不会被拦截。生产环境则交给 Nginx 做反向代理,效果相同。
5.4 常见报错对照表
| 报错现象 | 原因 | 处理方式 |
|---|---|---|
Port 8080 was already in use | 端口被占用 | 关掉占用进程,或修改application.yml的server.port |
Failed to load ApplicationContext | 数据库连接失败 | 检查 MySQL 是否启动、账号密码是否正确 |
java.sql.SQLException: Unknown database 'takeout' | 数据库没建 | 执行CREATE DATABASE takeout ... |
npm ERR! ERESOLVE unable to resolve dependency tree | 依赖版本冲突 | 执行npm install --legacy-peer-deps |
404 thrown for GET /api/shop/list | 代理没有匹配到路径 | 检查代理中的/api前缀和后端context-path是否一致 |
Invalid bound statement (not found) | MyBatis 接口与 XML 映射不匹配 | 检查mapper.xml的 namespace 和 mapper 接口路径 |
其中npm ERR! ERESOLVE在 Vue 2 + Vant 2 + Element UI 的老项目中非常常见,因为 Babel 与 eslint 的 peer dependency 互相冲突。--legacy-peer-deps是用来绕过检查的通用钥匙,但不代表依赖真的兼容,装完后一定要启动 dev server 跑一跑页面。
6. 进阶技巧:订单超时未支付的自动关单
外卖场景里,用户提交订单后不付款,不能让他永远占着库存。常见做法是给“待支付”状态加一个过期时间,用一个定时任务把超时订单改成已取消。
具体实现,在 Spring Boot 里加一个定时任务类:
@Component @Slf4j @RequiredArgsConstructor public class OrderTimeoutTask { private final OrderMapper orderMapper; @Scheduled(fixedDelay = 60000) public void closeExpiredOrders() { // 15 分钟前创建且未支付的订单,改为已取消 Date expireTime = new Date(System.currentTimeMillis() - 15 * 60 * 1000L); int count = orderMapper.closeExpiredOrders(expireTime); if (count > 0) { log.info("自动关闭超时订单 {} 单", count); } } }对应的 SQL 只更新状态为 0 的订单,避免误伤已支付记录:
UPDATE orders SET status = -1, update_time = NOW() WHERE status = 0 AND create_time < #{expireTime}@Scheduled(fixedDelay = 60000)表示上一次任务执行完后隔 60 秒再执行一次。相比fixedRate,fixedDelay在任务执行时间长于间隔时不会产生堆积。启动类上需要加@EnableScheduling,否则定时任务不生效。
一个必须注意的边界:定时任务轮询适合数据量不大的场景,订单表几十万条时全表 UPDATE 会很慢。常见优化是用LIMIT分批更新,比如每次只取 200 条待关闭订单处理,避免长时间锁表。另外,关闭订单时如果没检查菜品库存是否归还,会导致支付页显示有货、实际下单时库存不足——库存归还逻辑要和关单在同一个事务里处理。
如果更严格的实时性要求,可以再配合 Redis:下单时SET order:{orderNo} 1 EX 900,利用 key 过期机制触发关闭。但 Redis key 过期事件默认不开启,需要在 redis.conf 里配置notify-keyspace-events Ex,并且过期事件不保证准时,只适合做辅助兜底。毕业设计场景下,@Scheduled轮询已经足够,把上面这条乐观 UPDATE 讲清楚,答辩时就能从“会写 CRUD”提升到“考虑了数据一致性和并发边界”的档位上。
本文还有配套的精品资源,点击获取