简介:这份资源是面向计算机专业毕业生与Java Web初学者的一份完整毕业设计文档,围绕基于Spring Boot的校园食堂订餐系统展开,针对疫情下食堂拥挤、学生外卖饮食不健康、商家销售统计困难等现实痛点,给出从需求分析到系统测试的完整实现方案。压缩包内共1个doc文件,约3.32MB,即论文正文,涵盖系统总体设计、功能模块划分与测试调试等章节。文档详细说明了后台采用Spring Boot轻量框架与B/S架构,前端借助uni-app实现跨Android、iOS、H5及小程序的手机端点餐,数据库选用MySQL,并完整论述了菜品浏览、订单提交与限时修改撤销、一卡通在线支付、销售统计等核心功能的实现思路。目前已有9262人学习下载,适合作为毕业设计选题参考、论文写作模板或同类订餐系统开发的技术借鉴,读者可从中获取需求梳理、架构选型与功能设计的可复用经验。
1. 从一份毕业设计文档说起:这套校园食堂订餐系统到底能跑出什么
如果你手里拿到的是一份.doc格式的毕业设计文档,第一反应大概率是「这玩意儿能跑吗」。我拆过不少这类文档型项目,多数是需求分析、E-R 图、用例图堆了几十页,真正能落地的代码骨架反而藏在第 4 章「详细设计与实现」里。这份基于 Spring Boot 的校园食堂订餐系统就是典型:它把「学生手机端点餐 + 商家后台管菜品管订单 + 食堂管理员管商家」这条主线讲清楚了,技术栈是 Spring Boot 做后端、uni-app 做跨端前台、MySQL 存数据,B/S 架构,一卡通在线支付和菜品销售统计是它的两个差异化功能点。适合谁?适合拿它当毕业设计底座的学生,也适合想找一个「业务闭环完整、表结构不复杂」的 Spring Boot 练手项目的开发者。下面我按「它是什么 → 怎么跑起来 → 怎么改」的顺序,把这份文档里的工程骨架拆开讲。
2. 技术选型与整体架构:为什么是 Spring Boot + uni-app + MySQL 这套组合
2.1 后端为什么选 Spring Boot 而不是传统 SSM
文档里写得很直白:后台用 Spring Boot 新型轻量开发框架,B/S 架构。这个选择在校园订餐这个场景下是合理的,原因不在于「新」,而在于它把配置量压下来了。传统 SSM 要手写一堆applicationContext.xml、spring-mvc.xml、mybatis-config.xml,一个订餐系统光配置文件就能占掉半天工期;Spring Boot 的自动装配把这些收进 starter 里,开发者只需要在application.yml里写数据源和端口。
我一般会把这个项目的后端依赖理解成四块:spring-boot-starter-web负责 REST 接口,spring-boot-starter-jdbc或 MyBatis starter 负责数据访问,mysql-connector-java负责驱动,再加一个分页插件(文档第 4.1 节明确提到「分页显示」和「表格工具类」,说明作者用了 PageHelper 这类工具)。这套组合的典型pom.xml关键片段长这样:
<dependencies> <!-- Web 层:提供 @RestController、内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 数据访问:MyBatis 整合 Spring Boot --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <!-- MySQL 驱动,注意 8.0 版本要匹配 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 分页插件,对应文档里的「分页显示」公共模块 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency> </dependencies>这里有个参数细节值得说:MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,不是老版本的com.mysql.jdbc.Driver,连接串还要带serverTimezone=Asia/Shanghai,否则时间字段会差 8 小时。文档软件环境表里写的是 MySQL 8.0、Tomcat 7.0,但 Spring Boot 内嵌 Tomcat 版本由 starter 决定,不需要单独装 Tomcat 7,这一点是文档表述和实际工程容易对不上的地方,后面避坑章节会再提。
2.2 前台为什么用 uni-app 而不是纯 Vue
文档摘要里有一句关键信息:前台技术使用可跨 Android、IOS、H5、小程序的 uni-app 进行开发。这句话决定了这个项目的「手机端」不是原生 App,而是一套 Vue 语法写出来、编译到多端的代码。对毕业设计来说这是性价比最高的选择——学生不用学 Android 和 iOS 两套原生,写一遍页面就能在 H5 上演示,答辩时用手机浏览器打开即可。
uni-app 的页面结构是.vue单文件组件,一个菜品列表页的骨架大致如下:
<template> <view class="dish-list"> <!-- v-for 渲染后端返回的菜品数组,key 用 dishId 保证复用正确 --> <view class="dish-item" v-for="item in dishes" :key="item.dishId"> <image :src="item.imageUrl" mode="aspectFill" /> <text class="name">{{ item.dishName }}</text> <text class="price">¥{{ item.price }}</text> <button @click="addToCart(item)">加入购物车</button> </view> </view> </template> <script> export default { data() { return { dishes: [] }; }, onLoad() { // 页面加载时拉取当前食堂的菜品,canteenId 从路由参数取 this.fetchDishes(); }, methods: { async fetchDishes() { const res = await uni.request({ url: this.$baseUrl + '/api/dish/list', data: { canteenId: this.canteenId } }); this.dishes = res.data.data; }, addToCart(dish) { // 购物车状态建议放 Vuex,避免页面切换丢失 this.$store.commit('cart/add', dish); } } }; </script>这段代码里uni.request是 uni-app 封装的网络请求,onLoad是页面生命周期,和 Vue 的mounted类似但更早触发。$baseUrl需要在main.js里挂到原型上,指向后端服务地址。注意mode="aspectFill"是 uni-app 图片组件的裁剪模式,不写的话图片会变形,这是新手最容易忽略的样式参数。
2.3 整体分层与请求链路
把前后端拼起来看,这个系统的请求链路是:uni-app 页面 →uni.request→ Spring Boot Controller → Service → Mapper → MySQL → 逐层返回 JSON。文档第 4.1 节「公共模块类」里列了连接数据库、分页显示、返回信息格式、表格工具类四项,这其实就是后端的公共基础设施层。返回信息格式通常是一个统一响应体,类似{ code, msg, data },前端根据code判断成功失败。这个约定看起来简单,但它是前后端联调不吵架的前提——没有统一格式,前端每个接口都要写一套判断逻辑,后期改起来是血泪经验。
3. 目录结构与数据库设计:表怎么建、模块怎么分
3.1 后端工程目录的合理组织方式
文档没有给出完整目录树,但根据第 4 章的功能模块划分(食堂管理、菜品管理、订单管理、菜品统计、系统管理),一个合格的 Spring Boot 工程目录应该按「层 + 业务」组织,常见做法是这样:
canteen-order/ ├── src/main/java/com/canteen/ │ ├── CanteenApplication.java # 启动类,@MapperScan 扫 Mapper │ ├── common/ # 公共模块,对应文档 4.1 │ │ ├── Result.java # 统一返回格式 {code,msg,data} │ │ ├── PageResult.java # 分页返回封装 │ │ └── ExcelUtil.java # 表格导出工具类 │ ├── controller/ # 接口层 │ │ ├── DishController.java │ │ ├── OrderController.java │ │ └── CanteenController.java │ ├── service/ # 业务层 │ │ ├── DishService.java │ │ └── OrderService.java │ ├── mapper/ # 数据访问层 │ │ └── OrderMapper.java │ └── entity/ # 实体类,与表一一对应 │ ├── Dish.java │ ├── Orders.java │ └── Canteen.java └── src/main/resources/ ├── application.yml # 数据源、端口、MyBatis 配置 └── mapper/ # XML 映射文件 └── OrderMapper.xml这个结构的关键在于common包独立出来。文档 4.1 节把「连接数据库、分页、返回格式、表格工具」列为公共模块类,说明作者是有意识做基础设施复用的。我一般会把Result写成静态工厂方法,Result.success(data)和Result.fail(msg)两个入口,避免每个 Controller 手写new Result(200, "ok", data)。
3.2 核心数据表与字段设计
文档 3.3 节提到「系统模块属性及 E-R 图分析」和「数据表的创建」,虽然没有贴出完整建表语句,但根据功能需求可以反推出核心表。订餐系统的表关系是:一个食堂(canteen)下有多个商家/窗口,一个商家有多个菜品(dish),一个学生可以下多个订单(orders),订单里包含多个订单明细(order_item)。核心表设计如下:
-- 菜品表:商家维护的核心数据 CREATE TABLE dish ( dish_id BIGINT PRIMARY KEY AUTO_INCREMENT, canteen_id BIGINT NOT NULL COMMENT '所属食堂/商家', dish_name VARCHAR(64) NOT NULL COMMENT '菜品名称', price DECIMAL(10,2) NOT NULL COMMENT '单价,用 DECIMAL 不用 FLOAT', stock INT DEFAULT 0 COMMENT '供应量,对应文档的「数量」', material VARCHAR(255) COMMENT '原材料,对应文档的「原材料」', image_url VARCHAR(255) COMMENT '图片地址', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订单表:状态机是重点 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '学生/教职工 ID', canteen_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已完成 3已取消', pay_time DATETIME COMMENT '一卡通支付时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里有两个设计点值得展开。第一,price用DECIMAL(10,2)而不是FLOAT,金额字段用浮点数会出现0.1 + 0.2 != 0.3的精度问题,订单金额算错是致命的。第二,订单status用数字枚举而不是字符串,查询效率高,但要在代码里维护一个常量类或枚举,否则前端传"已支付"后端认1,联调必翻车。文档 2.1 节说订单管理要「修改订单状态」,这个状态流转就是订单模块的核心逻辑。
3.3 菜品统计功能的数据来源
文档把「菜品统计」单列为一个模块,功能是「查询菜品统计列表信息和导出菜品统计列表信息」,目的是让商家预估原材料采购量。这个功能不需要单独建统计表,常见做法是对orders和order_item做聚合查询:
-- 按菜品维度统计某天的销量,供商家备货参考 SELECT d.dish_name, SUM(oi.quantity) AS sold_count, SUM(oi.quantity * oi.unit_price) AS sold_amount FROM order_item oi JOIN orders o ON oi.order_id = o.order_id JOIN dish d ON oi.dish_id = d.dish_id WHERE o.status = 2 -- 只统计已完成订单 AND DATE(o.create_time) = #{date} GROUP BY d.dish_id, d.dish_name ORDER BY sold_count DESC;这个查询的关键是o.status = 2这个过滤条件。如果不过滤,待支付和已取消的订单也会被算进销量,商家按虚高的数字备货,第二天原材料就积压了。导出功能则是在这个查询结果上套一层ExcelUtil,用 POI 或 EasyExcel 写成.xlsx,文档 4.1 节的「表格工具类」大概率就是干这个的。
4. 核心模块代码拆解:登录、下单、订单状态与统计导出
4.1 登录模块与权限区分
文档 4.2 节专门讲了登录模块,2.2 节性能分析里也强调「系统对食堂管理员、商家、消费人群访问数据库设定不同的权限,通过系统登录功能加强系统安全性」。这说明登录不只是验证账号密码,还要区分角色。三种角色(管理员、商家、学生/教职工)登录后看到的菜单和能调的接口都不同。
一个务实的实现是登录成功后签发 token,把角色信息塞进 token 或 session,拦截器统一校验:
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 1. 按账号查用户,查不到直接返回失败,不暴露「账号不存在」细节 User user = userService.findByAccount(dto.getAccount()); if (user == null || !user.getPassword().equals(md5(dto.getPassword()))) { return Result.fail("账号或密码错误"); } // 2. 签发 token,payload 里带 userId 和 role String token = JwtUtil.sign(user.getUserId(), user.getRole()); // 3. 返回 token 和角色,前端据此跳不同首页 Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("role", user.getRole()); // ADMIN / MERCHANT / STUDENT return Result.success(data); } }参数说明:LoginDTO只有account和password两个字段;user.getRole()返回的是角色枚举字符串,前端拿到后决定跳/admin、/merchant还是/student。密码用 MD5 是这类项目的常见做法,但严格说 MD5 不加盐不安全,二次开发时建议换成 BCrypt,Spring Security 自带BCryptPasswordEncoder,改起来只是换一行加密调用。
拦截器部分用HandlerInterceptor在preHandle里解析 token,把role放进ThreadLocal,Controller 里就能直接取当前用户角色做鉴权。这套「token + 拦截器 + ThreadLocal」的组合是 Spring Boot 项目里最轻量的权限方案,比引入 Spring Security 全套配置省事得多,适合毕业设计体量。
4.2 下单流程:库存扣减与订单落库
下单是这个系统最核心也最容易出 bug 的链路。文档 2.1 节用户订餐流程图描述的是「选菜 → 提交订单 → 支付」这条线,但代码层面要处理三件事:校验库存、生成订单主表和明细、扣减库存。这三步必须在一个事务里,否则会出现「订单生成了但库存没扣」或者「库存扣了订单没生成」的脏数据。
@Service public class OrderServiceImpl implements OrderService { @Autowired private DishMapper dishMapper; @Autowired private OrderMapper orderMapper; @Override @Transactional(rollbackFor = Exception.class) // 任何异常都回滚 public Long createOrder(Long userId, List<OrderItemDTO> items) { BigDecimal total = BigDecimal.ZERO; // 1. 逐项校验库存并累加金额 for (OrderItemDTO item : items) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStatus() == 0) { throw new BizException("菜品已下架:" + item.getDishId()); } if (dish.getStock() < item.getQuantity()) { throw new BizException("库存不足:" + dish.getDishName()); } total = total.add(dish.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } // 2. 插入订单主表,status=0 待支付 Orders order = new Orders(); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 3. 插入明细 + 扣库存(用乐观锁防并发超卖) for (OrderItemDTO item : items) { orderMapper.insertItem(order.getOrderId(), item); int affected = dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BizException("库存扣减失败,请重试"); } } return order.getOrderId(); } }逻辑说明:@Transactional(rollbackFor = Exception.class)的rollbackFor必须显式写Exception.class,因为 Spring 默认只对RuntimeException回滚,抛受检异常时事务不回滚,这是经典坑。deductStock对应的 SQL 要带库存判断条件:
UPDATE dish SET stock = stock - #{quantity} WHERE dish_id = #{dishId} AND stock >= #{quantity};这条 SQL 的AND stock >= #{quantity}就是乐观锁,返回影响行数为 0 说明库存被别人抢完了,抛异常触发回滚。如果不加这个条件,两个用户同时下单会把库存扣成负数,这就是文档 1.2 节提到的「串单、漏单」问题的技术根源之一。
4.3 订单状态流转与限时修改撤销
文档明确要求「可以让学生提交订单、一定时间范围修改和撤销订单」。这个「一定时间范围」是业务规则,实现上通常是在订单表加create_time,撤销时判断当前时间与创建时间的差值:
public Result cancelOrder(Long orderId, Long userId) { Orders order = orderMapper.selectById(orderId); // 1. 只能撤销自己的订单 if (!order.getUserId().equals(userId)) { return Result.fail("无权操作该订单"); } // 2. 只有待支付状态可撤销 if (order.getStatus() != 0) { return Result.fail("当前状态不可撤销"); } // 3. 超过 15 分钟不允许撤销,给商家留备餐缓冲 long minutes = Duration.between(order.getCreateTime(), LocalDateTime.now()).toMinutes(); if (minutes > 15) { return Result.fail("已超过可撤销时间"); } orderMapper.updateStatus(orderId, 3); // 3 = 已取消 // 4. 回滚库存 orderMapper.restoreStock(orderId); return Result.success("已取消"); }参数说明:15 分钟这个阈值是业务参数,实际项目里应该放配置文件而不是硬编码,二次开发时改成@Value("${order.cancel.minutes:15}")更灵活。restoreStock是把明细里的数量加回dish.stock,这一步和下单扣库存必须对称,否则取消订单后库存永久少了。
4.4 菜品统计导出
统计模块的 Controller 层负责接收日期参数、调 Service 聚合查询、把结果交给 Excel 工具类写出。用 EasyExcel 的写法比原生 POI 简洁很多:
@GetMapping("/statistics/export") public void exportStatistics(@RequestParam String date, HttpServletResponse response) throws IOException { // 1. 聚合查询,返回 List<DishStatVO> List<DishStatVO> list = statisticsService.statByDate(date); // 2. 设置响应头,告诉浏览器这是下载文件 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=statistics_" + date + ".xlsx"); // 3. EasyExcel 一行写出,DishStatVO 上用 @ExcelProperty 标注列名 EasyExcel.write(response.getOutputStream(), DishStatVO.class) .sheet("菜品统计") .doWrite(list); }DishStatVO的字段上用@ExcelProperty("菜品名称")这类注解绑定表头,导出文件的列顺序就由字段顺序决定。这个模块的技术含量不高,但它是文档里商家「下达采购原材料」决策的数据来源,业务价值明确,答辩时是加分项。
5. 避坑与排查:跑这个项目最容易翻车的五个地方
5.1 启动报「Communications link failure」
现象:Spring Boot 启动时连不上数据库,控制台刷Communications link failure。原因通常是application.yml里的连接串少了时区参数,或者 MySQL 8.0 的驱动类名写成了老版本。解决:连接串改成jdbc:mysql://localhost:3306/canteen?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false,驱动类写com.mysql.cj.jdbc.Driver,同时确认 MySQL 服务真的在 3306 端口监听。
5.2 前端请求全部 404 或跨域被拦
现象:uni-app 在 H5 模式下调接口,浏览器控制台报 CORS 错误,或者请求路径对不上。原因有两个:一是后端没配跨域,二是$baseUrl写的是相对路径但 H5 和小程序的请求基址规则不同。解决:后端加一个全局跨域配置类实现WebMvcConfigurer的addCorsMappings,允许前端开发地址;$baseUrl在 H5 下用http://localhost:8080,真机调试时换成局域网 IP,不能用localhost,因为手机访问不到电脑的 localhost。
5.3 订单金额出现 0.30000000000000004
现象:两个菜品价格相加,数据库里存了0.30000000000000004。原因:金额字段用了FLOAT或DOUBLE,浮点数二进制表示不精确。解决:建表时金额一律DECIMAL(10,2),Java 侧用BigDecimal运算,且BigDecimal比较大小用compareTo不用equals(equals会比较 scale,1.0和1.00不相等)。
5.4 并发下单库存变负数
现象:压测或多人同时下单时,dish.stock出现负值。原因:扣库存的 SQL 只写了stock = stock - #{quantity},没有库存充足判断,或者校验和扣减不在同一事务。解决:扣减 SQL 加AND stock >= #{quantity}条件,判断影响行数;整个下单方法加@Transactional(rollbackFor = Exception.class);高并发场景再考虑 Redis 预扣库存,但毕业设计体量用乐观锁足够。
5.5 导出 Excel 中文乱码或文件打不开
现象:点导出按钮下载的.xlsx打开是乱码,或者提示文件损坏。原因:响应头Content-Type没设对,或者用response.getWriter()写了二进制流。解决:Content-Type必须是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,写出用response.getOutputStream()而不是getWriter(),文件名含中文时用URLEncoder.encode(fileName, "UTF-8")编码后再放进Content-Disposition。
6. 二次开发改造思路:从毕业设计到能真用的三个切入点
拿到这份文档和配套代码后,如果只是跑通演示,改改配置就够了;但如果想让它往「能真用」的方向走,我建议从三个地方下手,每个都有明确的验证方法。
第一个切入点是支付。文档写的是「线上学生一卡通支付功能」,毕业设计里大概率是模拟支付——点一下按钮把订单状态从 0 改成 1。真要接一卡通,得对接学校卡务系统的接口,通常是 HTTP 回调加签名验签。改造时先在OrderService里把支付逻辑抽成PaymentService接口,模拟实现和真实实现分开,这样演示环境不受影响。验证方法:用 Postman 模拟支付回调,确认订单状态流转和幂等性(同一笔回调重复到达不能重复改状态)。
第二个切入点是订单状态机。现在状态是 0 到 3 四个数字散落在代码里,改起来容易漏。我一般会抽一个枚举加状态流转校验表:
| 当前状态 | 允许操作 | 目标状态 | 触发角色 |
|---|---|---|---|
| 0 待支付 | 支付 | 1 已支付 | 学生 |
| 0 待支付 | 取消 | 3 已取消 | 学生 |
| 1 已支付 | 完成取餐 | 2 已完成 | 商家 |
| 1 已支付 | 退款 | 3 已取消 | 管理员 |
把这张表写成Map<OrderStatus, Set<OrderStatus>>放在 Service 里,任何状态变更前先查表,非法流转直接抛异常。验证方法:写一组单元测试,把表里每一行和几个非法组合都覆盖,跑通再上线。
第三个切入点是统计模块的性能。现在统计是实时聚合查询,数据量小没问题,订单上十万条后GROUP BY会变慢。改造思路是加一张按天汇总的统计表,每天凌晨定时任务把前一天的数据算好写进去,查询直接读汇总表。定时任务用 Spring 的@Scheduled(cron = "0 0 2 * * ?")就够,不需要引入 XXL-JOB 这类重型调度。验证方法:造十万条测试订单,对比改造前后统计接口的响应时间,一般能从秒级降到毫秒级。
最后说个我自己的习惯。这类文档型项目,我每次动手改之前都会先把原始代码完整跑一遍、把每个接口用 Postman 调通、把数据库表结构和文档里的 E-R 图对一遍,确认「基线可用」再动刀。有次跳过这步直接改订单模块,结果发现原始代码里库存扣减根本没加事务,改到一半才发现基线本身就有坑,返工了大半天。从那以后我每次接手这类项目,都强制先跑通基线再改,这个习惯帮我省下的时间比任何技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取