简介:这份资源是面向高校计算机相关专业学生与Java开发学习者的毕业设计完整文档,主题为基于Spring Boot的校园食堂订餐系统的设计与实现,可帮助读者理解如何将所学技术落地到真实校园服务场景中。系统针对疫情期间食堂拥挤、学生外卖依赖、就餐不便及商家经营效率低等问题,采用B/S架构,后台以Spring Boot轻量框架搭建,前端借助uni-app实现跨Android、iOS、H5及小程序的手机端访问,并使用MySQL完成数据存储。核心功能涵盖手机端菜品浏览、订单提交与限时修改撤销、校园一卡通在线支付以及菜品销售统计,论文还完整呈现了总体设计、编码实现、测试调试与未来展望等章节。资源包为1个doc文档,大小约3.32MB,结构完整、论述详实,适合作为同类订餐系统或JavaEE项目的选题参考与写作范本。目前已有9246人学习下载,具备较高的参考热度与借鉴价值。
1. 从一份毕设文档说起:这套校园食堂订餐系统到底能跑通什么
每年毕业季,后台总有人问我:有没有那种“技术栈主流、业务闭环完整、能直接照着搭起来”的毕设参考?我一般不会直接甩源码,而是先看它的设计文档能不能自圆其说。这份《基于 Spring Boot 的校园食堂订餐系统的设计与实现》就是典型样本——它不是玩具级的增删改查,而是把学生订餐、商家接单、一卡通支付、菜品销量统计串成了一条完整链路。后端用 Spring Boot 做轻量级服务,前端用 uni-app 一套代码同时覆盖 Android、iOS、H5 和小程序,数据库落在 MySQL 8.0,开发工具是 IDEA 和 WebStorm。如果你正在找一份能讲清楚“订单状态怎么流转、支付怎么对接、统计怎么做”的参考,这份文档的骨架值得拆开看。它适合两类人:一是需要快速理解 B/S 架构下订餐系统全貌的在校开发者,二是想拿它当模板改造成其他预约类系统的熟手。
2. 技术选型拆解:为什么是 Spring Boot + uni-app + MySQL 这套组合
2.1 后端为什么选 Spring Boot 而不是传统 SSM
这份文档里写得很直白:后台使用 Spring Boot 新型轻量开发框架。我翻过不少同类毕设,发现一个分水岭——用 SSM 的,光 XML 配置就能占掉论文三页;用 Spring Boot 的,启动类加几个注解就能跑。这不是偷懒,是选型逻辑变了。
校园食堂订餐系统的核心压力在饭点前后半小时:大量学生同时刷新菜单、提交订单、发起支付。Spring Boot 内嵌 Tomcat,配合默认的 HikariCP 连接池,省去了外置容器和繁琐的数据源配置。文档里提到的“及时性”和“准确性”两个性能指标,落到代码层面就是:订单接口响应要快,库存扣减不能超卖。
我一般会这样搭后端骨架:
// 启动类,开启事务管理和定时任务支持 @SpringBootApplication @EnableTransactionManagement @EnableScheduling public class CanteenApplication { public static void main(String[] args) { SpringApplication.run(CanteenApplication.class, args); } }逻辑说明:@EnableTransactionManagement是为了保证下单时“扣库存 + 生成订单 + 写支付流水”在一个事务里,避免部分成功导致串单。@EnableScheduling留给后面订单超时自动取消用。
参数层面,application.yml里我会把连接池调成这样:
spring: datasource: url: jdbc:mysql://localhost:3306/canteen?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 # 饭点并发高,默认10不够用 minimum-idle: 5 connection-timeout: 30000maximum-pool-size设 20 是经验值:按文档里“万人校园”的规模,同时在线选餐的峰值大概在 200 左右,20 个连接配合合理的 SQL 索引足够扛住。设太大反而会拖慢数据库。
2.2 前端为什么用 uni-app 而不是纯 Vue
文档里有一句关键描述:“前台技术使用可跨 Android、IOS、H5、小程序的 uni-app 进行开发”。这句话背后是真实的成本考量。如果只写 H5,学生得打开浏览器输网址;如果只做小程序,苹果用户和安卓用户又得分别审核。uni-app 的编译能力让一套代码同时出四个端,对校园项目来说,维护成本直接砍半。
实际开发时,菜品列表页我会这样写:
<template> <view class="dish-list"> <view v-for="item in dishes" :key="item.id" class="dish-item"> <image :src="item.imageUrl" mode="aspectFill"></image> <text class="name">{{ item.name }}</text> <text class="price">¥{{ item.price }}</text> <button @click="addToCart(item)" :disabled="item.stock <= 0"> {{ item.stock > 0 ? '加入购物车' : '已售罄' }} </button> </view> </view> </template> <script> export default { data() { return { dishes: [] }; }, onLoad() { this.fetchDishes(); }, methods: { async fetchDishes() { // 调用后端菜品列表接口,按食堂窗口分组 const res = await uni.request({ url: 'http://localhost:8080/api/dish/list', method: 'GET', data: { canteenId: 1, page: 1, size: 20 } }); this.dishes = res.data.data; }, addToCart(item) { // 库存为0时按钮已禁用,这里再做一次兜底校验 if (item.stock <= 0) return; this.$store.commit('cart/add', item); } } }; </script>逻辑说明:onLoad里拉取菜品,stock <= 0时按钮置灰并显示“已售罄”,这是防止用户把无库存菜品加进购物车。参数canteenId用来区分不同食堂,page和size做分页,避免一次拉取全部数据导致首屏卡顿。
2.3 MySQL 表结构设计的几个关键决策
文档第 3 章提到数据库设计原则和 E-R 图分析。我根据订餐业务的实际流转,把核心表关系理了一下:
| 表名 | 关键字段 | 设计意图 |
|---|---|---|
| user | id, student_no, card_no, role | 区分学生、教职工、商家、管理员 |
| canteen | id, name, location, status | 支持多食堂管理 |
| dish | id, canteen_id, name, price, stock, category | 菜品归属食堂,库存独立 |
| order | id, user_id, total_amount, status, create_time | 订单主表,status 控制流转 |
| order_item | id, order_id, dish_id, quantity, price | 订单明细,快照下单时价格 |
| payment | id, order_id, card_no, amount, pay_time | 一卡通支付流水 |
这里有个容易翻车的点:order_item里的price必须冗余存储。如果只存dish_id,商家改价后历史订单金额就全乱了。这是血泪经验,后面避坑章节还会展开。
3. 从零跑通核心链路:登录、选餐、下单、支付、统计
3.1 登录模块与权限拦截
文档第 4 章把登录模块放在公共模块之后,说明它是所有业务的前置。校园订餐系统有三类角色:学生/教职工、商家、食堂管理员。登录不能只验账号密码,还要根据角色返回不同的菜单权限。
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 1. 校验账号密码 User user = userService.findByStudentNo(dto.getStudentNo()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.fail("账号或密码错误"); } // 2. 生成 token,写入角色信息 String token = JwtUtil.generateToken(user.getId(), user.getRole()); // 3. 返回角色对应的前端路由权限 Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("role", user.getRole()); data.put("permissions", userService.getPermissions(user.getRole())); return Result.success(data); } }逻辑说明:密码用 BCrypt 加密存储,避免明文泄露。JWT token 里带role,后续接口用拦截器校验权限。permissions返回给前端做动态路由,学生看不到商家后台入口。
参数说明:studentNo是学号/工号,password前端传明文,后端比对哈希值。token 有效期我一般设 2 小时,饭点前重新登录一次,兼顾安全和体验。
3.2 订单状态机与库存扣减
这是整个系统最容易出 bug 的地方。文档里提到“学生可以提交订单、一定时间范围修改和撤销订单”,意味着订单状态不是简单的“已下单/已完成”,而是一个状态机。
我一般会定义这几个状态:
public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(1, "已支付"), PREPARING(2, "备餐中"), READY(3, "待取餐"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"), REFUNDED(6, "已退款"); private final int code; private final String desc; // 构造方法省略 }下单接口的核心逻辑:
@Transactional(rollbackFor = Exception.class) public Result createOrder(Long userId, List<OrderItemDTO> items) { // 1. 校验库存并扣减(用乐观锁防止超卖) for (OrderItemDTO item : items) { int affected = dishMapper.reduceStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BusinessException("菜品[" + item.getDishName() + "]库存不足"); } } // 2. 生成订单主表和明细 Order order = new Order(); order.setUserId(userId); order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); order.setCreateTime(new Date()); orderMapper.insert(order); for (OrderItemDTO item : items) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(item.getDishId()); oi.setQuantity(item.getQuantity()); oi.setPrice(item.getPrice()); // 快照价格 orderItemMapper.insert(oi); } // 3. 返回订单号,前端跳转支付 return Result.success(order.getId()); }逻辑说明:reduceStock的 SQL 是UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},利用数据库行锁保证原子性。如果返回 0 说明库存不够,抛异常回滚整个事务。@Transactional保证扣库存和写订单要么全成,要么全败。
参数说明:items是购物车传来的菜品列表,每个元素包含dishId、quantity、price。price从后端查库获取,不信任前端传值,防止篡改金额。
3.3 一卡通支付对接的模拟实现
文档提到“线上学生一卡通支付功能”。真实的一卡通接口通常由学校信息中心提供,但毕设环境一般用模拟支付。我一般会写一个支付网关抽象:
public interface PaymentGateway { PaymentResult pay(String cardNo, BigDecimal amount, String orderNo); PaymentResult refund(String orderNo, BigDecimal amount); } @Service public class MockCardPaymentGateway implements PaymentGateway { @Override public PaymentResult pay(String cardNo, BigDecimal amount, String orderNo) { // 模拟:卡号以 1 开头视为余额充足 if (cardNo.startsWith("1")) { // 写支付流水 Payment payment = new Payment(); payment.setOrderNo(orderNo); payment.setCardNo(cardNo); payment.setAmount(amount); payment.setPayTime(new Date()); paymentMapper.insert(payment); return PaymentResult.success("支付成功"); } return PaymentResult.fail("余额不足"); } }逻辑说明:接口抽象是为了后续替换成真实一卡通 SDK 时不用改业务代码。MockCardPaymentGateway用卡号前缀模拟成功/失败,方便测试。
参数说明:cardNo是学生一卡通卡号,amount是订单金额,orderNo是系统订单号。支付成功后更新订单状态为PAID,并触发备餐通知。
3.4 菜品统计的 SQL 实现
文档第 4.3.4 节提到菜品统计实现,商家要根据销量预估原材料。这个功能的核心是一条聚合查询:
SELECT d.name AS dish_name, SUM(oi.quantity) AS total_sold, SUM(oi.quantity * oi.price) AS total_amount, DATE(o.create_time) AS sale_date FROM order_item oi JOIN orders o ON oi.order_id = o.id JOIN dish d ON oi.dish_id = d.id WHERE o.status IN (1, 2, 3, 4) -- 只统计已支付及之后的订单 AND o.create_time BETWEEN #{startDate} AND #{endDate} GROUP BY d.id, DATE(o.create_time) ORDER BY total_sold DESC;逻辑说明:只统计PAID及之后状态的订单,排除待支付和已取消的。按菜品和日期分组,商家可以看到某道菜一周内的销量趋势。
参数说明:startDate和endDate由前端日期选择器传入,默认查最近 7 天。如果数据量大,建议在order.create_time和order_item.order_id上建联合索引。
4. 避坑与排查:订单超卖、支付回调丢失、跨域这些坑怎么填
4.1 现象:饭点高峰期订单超卖,库存扣成负数
原因:扣库存时先SELECT查库存,再UPDATE扣减,两个操作之间没有锁。并发上来后,多个请求同时查到相同库存,都以为够扣。
解决:改成原子更新UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},用数据库行锁保证一致性。如果返回影响行数为 0,直接抛异常回滚。这是最稳妥的方案,不需要引入分布式锁。
4.2 现象:学生支付成功但订单状态还是“待支付”
原因:支付回调接口没有做幂等处理,或者回调地址配置错误。模拟支付时如果前端直接改状态,后端没收到通知,就会出现数据不一致。
解决:支付成功后,前端轮询订单状态接口,后端在支付流水表里查order_no是否存在。如果存在但订单状态未更新,主动补偿更新。真实环境要加回调验签和重试机制。
4.3 现象:uni-app 请求后端接口报跨域错误
原因:开发阶段前端跑在localhost:8081,后端跑在localhost:8080,浏览器同源策略拦截。
解决:后端加全局跨域配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }注意:生产环境要把allowedOriginPatterns改成具体域名,不要用*配合allowCredentials(true),否则会有安全风险。
4.4 现象:商家修改菜品价格后,历史订单金额对不上
原因:order_item表只存了dish_id,没有存下单时的价格。商家改价后,关联查询出来的金额全变了。
解决:下单时把price快照到order_item表。统计和展示都用快照价格,不用dish表的当前价格。这个坑我在三个项目里见过,每次都是血泪教训。
4.5 现象:订单超时未支付,库存一直被占用
原因:没有定时任务清理超时订单,库存扣了但订单没支付,其他学生买不了。
解决:用@Scheduled每分钟扫描一次:
@Scheduled(fixedRate = 60000) public void cancelTimeoutOrders() { // 查询创建超过15分钟且状态为待支付的订单 List<Order> timeoutOrders = orderMapper.findTimeoutOrders(15); for (Order order : timeoutOrders) { // 恢复库存 List<OrderItem> items = orderItemMapper.findByOrderId(order.getId()); for (OrderItem item : items) { dishMapper.restoreStock(item.getDishId(), item.getQuantity()); } // 更新订单状态为已取消 orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED.getCode()); } }参数说明:fixedRate = 60000表示每分钟执行一次。15是超时分钟数,可根据食堂备餐时间调整。
5. 进阶技巧:用状态机引擎管订单流转,以及一份可复用的测试清单
订单状态流转如果全靠if-else判断,代码会膨胀得很快。我后来习惯引入轻量状态机,把流转规则抽出来。以 Spring StateMachine 为例,先定义状态和事件:
@Configuration @EnableStateMachine public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderStatus, OrderEvent> { @Override public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states) throws Exception { states.withStates() .initial(OrderStatus.PENDING_PAYMENT) .state(OrderStatus.PAID) .state(OrderStatus.PREPARING) .state(OrderStatus.READY) .state(OrderStatus.COMPLETED) .state(OrderStatus.CANCELLED) .end(OrderStatus.COMPLETED) .end(OrderStatus.CANCELLED); } @Override public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> transitions) throws Exception { transitions .withExternal().source(OrderStatus.PENDING_PAYMENT).target(OrderStatus.PAID).event(OrderEvent.PAY) .and() .withExternal().source(OrderStatus.PENDING_PAYMENT).target(OrderStatus.CANCELLED).event(OrderEvent.CANCEL) .and() .withExternal().source(OrderStatus.PAID).target(OrderStatus.PREPARING).event(OrderEvent.ACCEPT) .and() .withExternal().source(OrderStatus.PREPARING).target(OrderStatus.READY).event(OrderEvent.FINISH) .and() .withExternal().source(OrderStatus.READY).target(OrderStatus.COMPLETED).event(OrderEvent.PICKUP); } }逻辑说明:状态机把“什么状态下能触发什么事件”集中管理,业务代码里只需要stateMachine.sendEvent(OrderEvent.PAY),不用再写一堆if (status == PENDING_PAYMENT)。新增状态或调整流转规则时,改配置类就行,不会漏改。
参数说明:OrderStatus和OrderEvent都是枚举。withExternal表示外部状态转换,转换后触发持久化操作。
测试环节,文档第 5 章提到黑盒测试。我一般会准备一份覆盖核心链路的清单:
| 测试场景 | 输入 | 预期结果 |
|---|---|---|
| 正常下单 | 库存充足,余额充足 | 订单创建成功,库存扣减,状态待支付 |
| 库存不足 | 购买数量大于库存 | 返回“库存不足”,事务回滚 |
| 支付成功 | 卡号以1开头 | 订单状态变已支付,写支付流水 |
| 支付失败 | 卡号不以1开头 | 订单保持待支付,提示余额不足 |
| 取消订单 | 待支付状态取消 | 库存恢复,状态变已取消 |
| 超时未支付 | 创建后15分钟未支付 | 定时任务取消订单,恢复库存 |
| 商家接单 | 已支付订单点接单 | 状态变备餐中 |
| 统计查询 | 选择日期范围 | 返回菜品销量和金额汇总 |
这份清单我每次改完订单模块都会跑一遍,尤其是库存和状态流转相关的用例。从那以后我每次动订单状态机之前,都强制先把这份清单过一遍,确认没有遗漏的流转分支。希望帮到你。
本文还有配套的精品资源,点击获取