简介:面向计算机类毕业设计的Spring Boot高校食堂移动预约点餐系统源码包,适合学生参考学习、课程设计及全栈项目实战。实现以后端Java源码与前端Vue组件为主,配合微信小程序页面,覆盖登录认证、餐品浏览、预约下单、订单管理等常见业务模块,并携带SQL数据库脚本、Maven配置及一键部署脚本,便于快速运行与二次开发。包内共1338个文件,压缩包约78.34MB,包含PNG界面素材、JS脚本、Vue组件、Java类、JSON配置及小程序页面等,另附MP4演示视频,可按目录快速定位代码。从项目初始化、环境搭建到前后端联调,均有相应代码与脚本支撑,目录按功能模块组织,配合演示视频能够直观了解预约点餐全流程。整体结构清晰,已有103人学习/浏览,对希望上手Spring Boot小程序实战项目的读者很有帮助。
1. 拆解一个Spring Boot食堂预约点餐系统:从源码结构看真实毕设项目
拿到这套项目压缩包,结尾写着“源码+视频”,文件列表里躺着多个.vue.bak备份文件和三个 Windows 批处理脚本:1-install.bat、2-run.bat、3-build.bat。这不是一个只讲概念的 PPT 项目,而是一套能跑起来的前后端分离应用:后端是 Spring Boot,前端是 Vue,数据库用 MySQL,构建走 Maven。系统解决的业务痛点很具体——高校食堂午高峰排队时间长,学生需要提前在手机端选食堂、选窗口、选菜品,并预约一个取餐时间段,食堂后台按预约单备餐。我会以这份源码为线索,从数据表设计、订单状态机、后端接口、移动端联调、Windows 部署脚本五个方面拆解,适合正在做 Spring Boot 毕业设计,或者想搞懂前后端分离项目真实结构的人。下面直接进入工程内部。
2. 预约点餐的数据建模与订单状态机设计
2.1 核心表结构:食堂、窗口、菜品、订单四层模型
这套系统的业务主线很清晰:用户登录后先选食堂,再选该食堂下的窗口,最后从窗口菜单里挑菜品,把菜品加入“预约单”,提交时选择一个具体日期和时间段。所以表结构一定围绕“食堂—窗口—菜品—订单”这条链展开。
从源码里的实体类定义来看,核心表大致如下:
| 表名 | 关键字段 | 作用 |
|---|---|---|
sys_user | id、username、password、role | 存学生和管理员账号 |
canteen | id、name、location、open_time | 高校内多个食堂 |
canteen_window | id、canteen_id、name、status | 食堂下的窗口 |
dish | id、window_id、name、price、image | 窗口下的菜品 |
time_slot | id、start_time、end_time、max_orders | 可预约时间段及名额 |
orders | id、user_id、canteen_id、reserve_date、time_slot_id、status、total_amount | 预约订单主表 |
order_item | id、order_id、dish_id、dish_name、price、quantity | 订单明细 |
这里最关键的是一张orders表。它不像普通电商订单那样只记商品,而是额外记录了reserve_date和time_slot_id,这两个字段把“点餐”和“预约取餐”绑定在一起。time_slot表里的max_orders字段控制每个时间段最多接多少单,是防超卖的源头。
下面给出建表 SQL 的关键部分,注意字段注释和字符集。
CREATE TABLE `canteen` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '食堂名称', `location` varchar(128) DEFAULT NULL COMMENT '校区/楼层位置', `open_time` varchar(32) DEFAULT NULL COMMENT '营业时间,如 10:30-13:30', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食堂表'; CREATE TABLE `time_slot` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `start_time` varchar(16) NOT NULL COMMENT '开始时间,如 11:00', `end_time` varchar(16) NOT NULL COMMENT '结束时间,如 11:30', `max_orders` int(11) NOT NULL DEFAULT 50 COMMENT '该时段预约上限', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约时段表'; CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,可用时间戳+随机数生成', `user_id` bigint(20) NOT NULL, `canteen_id` bigint(20) NOT NULL, `reserve_date` date NOT NULL COMMENT '预约日期', `time_slot_id` bigint(20) NOT NULL COMMENT '预约时段', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2备餐中 3待取餐 4已完成 5已取消', `total_amount` decimal(8,2) NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';三个表的建表逻辑看起来简单,实际设计时有两个细节容易被忽略。第一个是orders里的canteen_id和time_slot_id都要建索引,因为查询“某个食堂今天所有预约单”和“某个时段已预约数量”会频繁走这两个字段。第二个是time_slot的max_orders是纯数字字段,不要用字符串,否则后续做count(*) < max_orders比较时会有隐式转换问题,索引也会失效。
2.2 订单状态机:从“待支付”到“已完成”的流转约束
预约订单不是一张静态记录,它有完整的状态生命周期。源码中用tinyint存状态码,因为简单且易排序。状态流转如下:
| 状态值 | 含义 | 允许流转到 | 触发动作 |
|---|---|---|---|
| 0 | 待支付 | 1、5 | 模拟支付成功 / 用户取消 |
| 1 | 已支付 | 2、5 | 食堂开始备餐 / 需退单 |
| 2 | 备餐中 | 3 | 食堂操作备餐完成 |
| 3 | 待取餐 | 4 | 用户凭取餐码取餐 |
| 4 | 已完成 | 无 | 交易闭环 |
| 5 | 已取消 | 无 | 终止状态 |
这个状态机的价值在于约束非法操作。比如用户不能把已支付订单直接改成已完成,必须经过“备餐中”和“待取餐”。在写 Service 层时,要把状态判断放在updateSQL 的where条件里,而不是先查出状态再用 Java 判断后再更新,否则会有并发缝隙。
UPDATE orders SET status = #{targetStatus} WHERE id = #{orderId} AND status = #{currentStatus}影响行数为 1 说明状态流转成功,为 0 说明当前状态已被其他请求改变,需要回滚或提示。这种写法比select + update安全得多,也是源码里OrderMapper.updateStatusByCurrentStatus的核心逻辑。
2.3 Mapper 层联表查询:订单列表的一次性查全
点餐系统里最常见的查询是“我的预约列表”,页面除了订单主数据,还要显示食堂名、时间段、菜品名和数量。在传统 MyBatis XML 里,这个列表可以直接用join查出来,避免 N+1 次查询。
<select id="selectOrderList" resultType="com.canteen.vo.OrderVO"> SELECT o.id AS orderId, o.order_no AS orderNo, c.name AS canteenName, t.start_time AS startTime, t.end_time AS endTime, o.reserve_date AS reserveDate, o.total_amount AS totalAmount, o.status, GROUP_CONCAT(d.name, 'x', oi.quantity SEPARATOR ';') AS dishSummary FROM orders o LEFT JOIN canteen c ON o.canteen_id = c.id LEFT JOIN time_slot t ON o.time_slot_id = t.id LEFT JOIN order_item oi ON o.id = oi.order_id LEFT JOIN dish d ON oi.dish_id = d.id WHERE o.user_id = #{userId} GROUP BY o.id, c.name, t.start_time, t.end_time, o.reserve_date, o.total_amount, o.status ORDER BY o.create_time DESC </select>这里用GROUP_CONCAT把多个菜品拼成一个字符串,前端拿到dishSummary直接渲染,省去二次解析。代价是返回的 VO 需要多一个dishSummary字段,且GROUP BY必须把主表所有查询列写全,否则 MySQL 开启ONLY_FULL_GROUP_BY时会直接报错。实际源码中如果不想用字符串拼接,也可以在 Java 里对同一订单的多条明细二次聚合,但一周预约几百条数据时,join + group_concat的响应时间和代码量都更优。
3. 后端接口实现:预约下单、防超卖与状态推进
3.1 Controller 层:RESTful 接口与统一返回体
前端移动端所有请求都走后端/api前缀。源码中的 Controller 写得很规整,每个接口只做参数接收和结果封装,不写业务逻辑。接口清单如下:
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/user/login | 登录,返回 token |
| GET | /api/canteen/list | 获取食堂列表 |
| GET | /api/canteen/windows?canteenId= | 获取食堂下窗口 |
| GET | /api/dish/list?windowId= | 获取窗口菜品 |
| GET | /api/time-slots | 获取可预约时段 |
| POST | /api/order/create | 创建预约订单 |
| GET | /api/order/current | 查询用户预约列表 |
| POST | /api/order/pay | 模拟支付 |
| POST | /api/order/cancel | 取消订单 |
以创建订单接口为例,Controller 只有最简单的四层结构。
@RestController @RequestMapping("/api/order") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping("/create") public Result<Long> create(@RequestBody @Valid OrderCreateReq req) { Long orderId = orderService.createOrder(req); return Result.success("预约成功", orderId); } }@Valid会触发OrderCreateReq内的@NotNull、@Size校验,减少 Service 层对空参数的判断。Result<T>是一个统一的 JSON 返回体,内部持有code、message、data三个字段。这种写法在 Spring Boot 项目中非常常见,好处是前端可以统一拦截非code=0的返回,而不是每个接口单独判断失败结构。
3.2 Service 层:@Transactional 保证下单原子性
创建预约是整个系统中逻辑最复杂的操作。它需要同时检查时段名额、插入订单主表、插入明细表、扣减名额,任何一个失败都不能留下半截数据。源码里在 Service 方法上加了@Transactional,这是 Spring Boot 对数据库事务最常规的声明式控制。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateReq req) { // 1. 查询时间段,使用悲观锁锁定记录 TimeSlot slot = timeSlotMapper.lockSlotById(req.getTimeSlotId()); if (slot == null) { throw new BizException("预约时段不存在"); } // 2. 校验当前时段已预约人数是否达到上限 Integer orderedCount = orderMapper.countBySlotAndDate( req.getTimeSlotId(), req.getReserveDate()); if (orderedCount >= slot.getMaxOrders()) { throw new BizException("该时段预约名额已满"); } // 3. 构造订单主表记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(req.getUserId()); order.setCanteenId(req.getCanteenId()); order.setReserveDate(req.getReserveDate()); order.setTimeSlotId(req.getTimeSlotId()); order.setStatus(0); order.setTotalAmount(req.getTotalAmount()); orderMapper.insert(order); // 4. 插入订单明细 for (OrderItem item : req.getItems()) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return order.getId(); }第一行lockSlotById对应 SQL 里的SELECT * FROM time_slot WHERE id = #{id} FOR UPDATE。它的意思是:在事务提交前,把这一行锁住,其他请求尝试锁同一行时会阻塞。这样第 2 步的count查询和第 3 步的插入就被保护在同一把锁内,不会出现两个人同时看到剩余 1 个名额并且都下单成功的情况。
rollbackFor = Exception.class很关键。Spring 默认只对运行时异常回滚,如果业务校验抛的是自定义BizException并且继承了RuntimeException,那可以不用写;但为了保险,源码常用这种方式把检查异常也纳入回滚范围。事务一旦抛出异常,订单主键和明细都会回滚,不会留下脏数据。
3.3 并发更高时:从“行锁”到“版本号”的取舍
上面的改法在高校食堂场景下够用,因为它面向的不是高并发抢课。但如果把预约系统推广到全校用户,同一时段可能涌入上千请求,FOR UPDATE的串行化会把吞吐量拉低。一个折中方案是把time_slot表加一个version字段,用乐观锁代替悲观锁。
UPDATE time_slot SET ordered_count = ordered_count + 1, version = version + 1 WHERE id = #{slotId} AND version = #{oldVersion} AND ordered_count < max_orders这条UPDATE同时完成“判满”和“扣减”,如果影响行数为 0,说明版本被改过或名额已满,需要重试或提示失败。相比FOR UPDATE,它不持有数据库锁,并发度更高,但实现起来要对失败重试做额外处理,而且要求time_slot表里有ordered_count字段。源码里更可能用的是悲观锁方案,因为毕业设计重点是结构完整性,不是十万级并发。
4. Vue移动端与前后端联调:从登录、点餐到提交预约
4.1 前端工程结构:从备份文件名看组件划分
打开源码里的前端目录,会看到一批.vue.bak文件,比如IndexMain.vue.bak、IndexHeader.vue.bak、BreadCrumbs.vue.bak。.bak后缀说明这是开发过程中被覆盖过的文件,也侧面展示了这套前端的组件拆分方式是“布局组件 + 业务页面”。移动端的首屏是IndexMain.vue,负责展示食堂列表和窗口入口;IndexHeader.vue是顶栏,用来放位置和用户信息;BreadCrumbs.vue是面包屑导航,在选食堂、选窗口、确认订单这些多级跳转页面之间提供返回路径。
组件路由一般是这样划分的:
views/ Login.vue // 登录页 CanteenList.vue // 食堂列表 WindowList.vue // 窗口列表 DishList.vue // 选菜页 + 购物车 OrderConfirm.vue // 确认预约信息 OrderList.vue // 我的预约这个结构和后端接口一一对应,理解起来很直观。需要提的是移动端页面大多依赖一个全局的flex布局,把底部按钮固定在手机上,避免键盘弹出时错位。
4.2 Axios API 封装与跨域代理
Vue 项目里所有请求都走 Axios,源码在utils/request.js里做了封装。
import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { return Promise.reject(new Error(res.message)) } return res }, error => { return Promise.reject(error) } ) export default servicebaseURL用/api而不是完整域名,是因为开发阶段在vue.config.js里配置了代理。前端跑在 8081 端口,后端 Spring Boot 跑在 8080 端口,如果不做代理,浏览器直接请求http://localhost:8080/api/...会碰到跨域问题。所以实际配置如下:
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }changeOrigin: true会把请求头里的Host改写成localhost:8080,让后端分不清请求是来自前端还是外部。这种代理方式的优势是上线后可以用 Nginx 做同样的转发,前端代码不用改baseURL。
4.3 提交预约的真实调用链:从组件 method 到后端 Controller
在OrderConfirm.vue里,用户核对完选中的菜品和预约时间后,点击“提交预约”,会调用一个submitOrder方法。代码里最关键的是把参数整理成后端OrderCreateReq的结构。
async submitOrder() { if (this.loading) return this.loading = true const params = { userId: this.userInfo.id, canteenId: this.selectedCanteen.id, timeSlotId: this.selectedSlot.id, reserveDate: this.reserveDate, totalAmount: this.totalPrice, items: this.selectedDishes.map(dish => ({ dishId: dish.id, dishName: dish.name, price: dish.price, quantity: dish.quantity })) } try { const res = await this.$http.post('/order/create', params) if (res.code === 0) { this.$router.push({ path: '/order/success', query: { orderId: res.data } }) } } finally { this.loading = false } }这里的this.$http就是上面封装的service实例。res.data是后端返回的订单 ID,跳转到成功页时可以拿它生成取餐码。注意this.loading的作用是防止用户双击按钮导致创建两条订单,这在移动端是常见问题。
调用链是:Vue 组件 method → Axios 拦截器 → vue.config.js 代理 → Spring Boot Controller → Service → Mapper。一条新业务从开发到联调,只要按这个链路检查,基本能定位 90% 的问题。前端报 404 时先看代理是否生效,报 401 时先看登录 token 是否放进请求头,报 500 时直接看后端日志堆栈。
5. 部署运行与排错:基于bat脚本的Windows一键启动
5.1 三个bat脚本分别做了什么
压缩包里带着1-install.bat、2-run.bat、3-build.bat,这是作者在 Windows 上反复安装、运行、打包留下的工具。三个脚本的分工可以用下表说明。
| 脚本 | 目标 | 典型内容 |
|---|---|---|
1-install.bat | 安装依赖 | 后端mvn clean install -DskipTests,前端npm install |
2-run.bat | 启动开发环境 | 后端mvn spring-boot:run,前端npm run serve |
3-build.bat | 打生产包 | 后端mvn package,前端npm run build |
一个典型的1-install.bat内容可以写成这样:
@echo off echo [1/2] install backend... call mvn clean install -DskipTests echo [2/2] install frontend... cd frontend call npm install pause注意call和pause的使用。bat脚本直接执行mvn时,如果命令不存在或失败,窗口可能一闪而过;加上call可以等待一个命令执行完再执行下一个,pause则让窗口停在错误信息上,方便排查。2-run.bat里建议用start打开两个新窗口分别跑前后端,避免前端npm run serve阻塞后端的启动。
5.2 启动失败最常见的四个原因
第一是数据库连接。源码的application.yml默认连localhost:3306,本机 MySQL 如果密码不是root,要同步改username和password。第二是端口冲突。前端代理写死了localhost:8080,后端端口被别的进程占用时,接口会一直超时。第三是前端依赖缺失。如果只运行了2-run.bat而没有先执行1-install.bat,npm run serve会直接提示找不到node_modules目录。第四是 Java 版本不匹配。Spring Boot 2.x 用 Java 8 编译,机器只装 Java 17 时会出现UnsupportedClassVersionError。
检查顺序是:看后端日志Tomcat started on port 8080→ 看前端启动是否出现Compiled successfully→ 浏览器访问http://localhost:8081→ 打开控制台看网络请求是否被代理转发。
5.3 进阶压测与调优:把“预约名额”放进 Redis
以上部署步骤已经能够支撑演示和答辩。如果想让系统在压测中表现更好,可以做一个小改造:在time_slot之外增加 Redis 计数器,把时段名额扣减从数据库移到内存。
// 方案:用 Redis 的 DECR 命令实现原子扣减 String key = "slot:count:" + slotId + ":" + reserveDate; Long remain = stringRedisTemplate.opsForValue().decrement(key); if (remain != null && remain < 0) { stringRedisTemplate.opsForValue().increment(key); throw new BizException("名额已满"); }启动时先把max_orders写入 Redis,每次下单执行decrement,结果为负就回滚计数并提示失败。这样可以显著降低数据库锁竞争,但要注意 Redis 和数据库计数的一致性——事务提交失败时要补偿回滚,定时任务定期把 Redis 计数同步回数据库。剩下的建议直接执行1-install.bat看一遍全流程,再对照源码逐行验证这些设计。
本文还有配套的精品资源,点击获取