做Java毕业设计选什么题目,是每年都有一批同学纠结的事。我这个过来人给一个思路:把目光投向校园里每天都在发生的高频场景——食堂。一到饭点,窗口前排起长队,阿姨一边打菜一边算账,后面的人伸着脖子看今天有什么菜,刷卡机偶尔还会卡壳。这种体验我相信每个人都经历过,而它背后正好藏着一个完整、有实际价值、又能把SpringBoot全家桶串起来的项目。我的毕业设计题目就定为“米果智能食堂管理系统”,本质上是用Java和SpringBoot搭建一个Web版的校园食堂线上订餐平台,让用户可以在网页上浏览菜品、加购物车、下单支付,食堂端接单出餐,管理端查看营业数据。这篇文章把我从需求分析、库表设计、核心代码实现到踩坑debug的整个过程写出来,希望能给正在做同类题目或者想用SpringBoot多模块练手的同学一些可以直接借鉴的经验。
很多同学会问,食堂订餐系统网上不是有一堆现成的吗?确实有,但把需求做透、把并发和库存问题真正处理好,就是另一回事了。下面我按项目的实际推进顺序,从选型的底层逻辑说到具体实现细节。
1. 为什么“智能食堂点餐”适合作为Java毕设题目,或者说,它到底解决了什么问题
先别急着写代码,把题目背后的需求看明白,后面设计表结构、划分模块时思路会顺很多。
1.1 真实场景里的痛点,就是系统要解决的需求
校园食堂的几个典型痛点,我总结下来是这几条:
- 排队时间长:下课时间集中,窗口数量有限,点餐、结算、取餐全在窗口完成,效率被卡在“人”身上。
- 信息不透明:学生走到窗口才知道今天有什么菜,价格也是到跟前才看,没法提前安排。
- 错峰能力弱:所有人都挤在中午12点,食堂容量被顶到极限,但11点和13点又比较空。
- 统计靠人工:哪个菜卖得好、每天营业额多少、原材料需要备多少,基本靠食堂管理员拍脑袋。
智能食堂点餐平台本质上就是把“点餐”这个动作从窗口前移到网页上:学生提前浏览菜品、加购物车、提交订单,食堂端根据订单统一备餐,用户按照约定时间到窗口取餐。这样既压缩了现场决策时间,也能让食堂掌握更准确的备餐数据。放在毕设语境里,这个需求足够真实,又不至于复杂到一个人做不完,是一个很恰当的课题边界。
1.2 这个题目的价值,从“毕设评估”的三个角度来打分
我当年评估题目时,是从三个维度看的:
第一,技术覆盖面。这个题目能自然地把SpringBoot、MyBatis-Plus、MySQL、Redis、前端Vue/HTML模板、文件上传、定时任务、接口鉴权串在一起,技术栈主流,面广但都有成熟方案,不会做到一半卡死。
第二,业务闭环完整。从用户注册登录,到菜品浏览、加购物车、提交订单、支付(模拟)、食堂接单、出餐、评价,再到管理端的营业统计,整条路径是通的。毕设答辩时,老师最看重的是“你做的系统是一个能跑通的完整业务闭环”,而不是一个只写了一半的Demo。
第三,有可深挖的难点。一个看起来平平无奇的订餐系统,往里挖会发现并发扣库存、超时未支付订单释放、订单状态机等问题。这些点恰恰是答辩时用来证明“这个项目确实是我想清楚了”的关键素材,也是实际开发中最容易踩坑的地方。
1.3 系统角色的划分,决定了功能清单
我把使用角色拆成三类,每个角色对应一套功能清单。这样的划分直接决定了后面前端页面和后台接口的组织方式。
| 角色 | 核心功能 | 说明 |
|---|---|---|
| 普通用户(学生/教职工) | 注册登录、浏览菜品、加入购物车、提交订单、模拟支付、查看订单状态、催单、评价反馈 | 系统的前端主流程,占了80%的页面量 |
| 食堂管理员 | 菜品管理、库存管理、分类管理、接单出餐、查看当日订单、营业数据统计 | 后台业务的核心,订单状态流转主要在这个端 |
| 系统管理员 | 用户管理、食堂信息管理、数据大屏展示、基础参数配置 | 偏运维和全局管理,页面不多但必须有 |
这里有个容易被忽略的细节:大多数毕设项目只做了“用户下单”这一半,食堂端往往只是对订单做个列表展示。我建议把“接单出餐”这个环节做成真正的状态流转——食堂管理员可以点击“开始制作”“出餐完成”,这样订单状态就不再是死数据,而是有生命周期的业务对象,答辩时可以说清楚你设计的状态机。
2. 技术栈选型与项目整体架构:为什么是SpringBoot,而不是更“老”的方案
选题定了以后,第二步是确定技术方案。这一点我的建议很明确:只要不是学校强制要求,直接上SpringBoot。
2.1 SpringBoot解决的是“配置地狱”,让你把时间花在业务上
很多教材还在教SSM框架(SpringMVC + Spring + MyBatis)的XML配置,十几行的bean定义、数据源配置、事务配置,手动拼得眼花缭乱。SpringBoot把这些约定成俗的东西用自动配置吃掉,一个spring-boot-starter-web加进来,内嵌Tomcat,main方法一启动,Web服务就起来了。我做这个项目时最大的感受是:SpringBoot不是帮你省掉了学习底层的步骤,而是让你在毕设周期内,把有限的时间花在业务逻辑、数据库设计这些更有价值的事情上。
当然,这不意味着可以完全不懂原理。比如@SpringBootApplication为什么一个注解就能开启自动配置,@EnableAutoConfiguration加载了什么,这些是答辩时大概率会被问到的,还是得能讲明白。
2.2 分层架构:Controller-Service-Mapper怎么分工
项目采用前后端分离的开发方式。后端是标准的SpringBoot三层架构:
- Controller层:负责接收HTTP请求、参数校验、返回统一响应结果,不写任何业务逻辑。
- Service层:业务逻辑的核心,包括事务控制、库存扣减、订单状态流转等。
- Mapper层(DAO层):用MyBatis-Plus封装好的
BaseMapper完成数据库操作,复杂SQL再单独写。
我用的技术组合是这样的:
| 组件 | 选型 | 理由 |
|---|---|---|
| 基础框架 | SpringBoot 2.7.x | 稳定、教程多、兼容MySQL驱动方便 |
| ORM框架 | MyBatis-Plus | 单表CRUD不用写XML,自带分页插件和代码生成器 |
| 数据库 | MySQL 8.0 | 社区版免费、生态成熟、教务环境基本都装了 |
| 缓存/分布式锁 | Redis | 缓存菜品热度、分布式锁防止库存超卖 |
| 前端框架 | Vue 3 + Element-Plus | 组件化开发快,Element-Plus的表格和表单能省一半工作量 |
| 接口文档 | knife4j (OpenAPI3) | 自动生成接口文档,自测和答辩演示都方便 |
2.3 项目目录结构,直接照着搭就行
一个清晰的目录结构是后期不混乱的前提,我建议按“模块分包”而不是“按功能堆类”来组织。下面是我的目录结构,可以参考:
migu-canteen/ ├── src/main/java/com/example/migu/ │ ├── MiguApplication.java # 启动类 │ ├── common/ # 通用模块 │ │ ├── Result.java # 统一响应封装 │ │ ├── PageResult.java # 分页响应封装 │ │ └── GlobalExceptionHandler.java # 全局异常拦截 │ ├── config/ # 配置类:MybatisPlus、Redis、CORS、定时任务 │ ├── controller/ # 控制层 │ │ ├── UserController.java │ │ ├── DishController.java │ │ └── OrderController.java │ ├── service/ # 业务层 │ │ ├── DishService.java │ │ └── impl/ │ ├── mapper/ # 数据访问层 │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求/响应对象 │ └── utils/ # 工具类:JWT、Redis操作 └── src/main/resources/ ├── application.yml └── mapper/ # 复杂SQL的XML文件这个结构的关键点在于:entity只对应数据库表结构,dto专门用来和前端交互。很多同学刚开始喜欢直接返回Entity给前端,结果密码、状态码这些字段全暴露了,迟早要返工。从第一天就养成Entity和DTO分离的习惯,后面省心得多。
3. 数据库设计:订单、菜品、库存之间的“联动关系”是核心
数据库设计是整个项目的地基。我第一版是照着“有哪些页面”来建表的,结果做到订单模块时发现缺东少西,又回头改表结构,白白浪费了两天。正确的做法是:先梳理业务流程,再根据流程里的每个动作设计表。
3.1 核心业务链路
整个点餐业务的核心链路是:
用户浏览菜品 → 加入购物车 → 提交订单(生成订单主表和明细表) → 模拟支付(修改订单状态) → 食堂接单 → 制作出餐 → 完成订单 → 用户评价
从这个链路反推,核心表至少要有这些:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| user | id, username, password, nickname, avatar, phone, role | 存放用户和管理员信息,用role字段区分角色 |
| canteen | id, name, address, open_time, close_time | 食堂基本信息,为将来扩展多食堂做准备 |
| category | id, name, sort, canteen_id | 菜品分类,比如套餐、盖饭、饮品 |
| dish | id, name, image, price, category_id, status, sales, stock | 菜品基本信息,冗余一个stock字段表示库存 |
| cart_item | id, user_id, dish_id, quantity, status | 购物车条目 |
| order_info | id, order_no, user_id, canteen_id, total_amount, status, remark, create_time, pay_time, finish_time | 订单主表,记录一笔订单的整体状态 |
| order_item | id, order_id, dish_id, dish_name, dish_image, price, quantity | 订单明细表,记录下单时每道菜的信息 |
| comment | id, user_id, order_id, content, rating, create_time | 用户对菜品或订单的评价 |
3.2 两个关键设计决策:为什么要冗余字段
建表时有三个点,我当时犹豫过,最后踩坑踩出的结论:
第一,订单明细表里为什么重复存dish_name和dish_image?因为菜品的价格和名称是会变的——搞一次打折活动,或者食堂改了菜名,如果你在订单明细里不冗余存储,历史订单显示的价格就跟着变了,这不符合业务直觉。用户看到的是“下单时的价格”,不应该是“现在的价格”。这就是快照思想,在电商、外卖系统里都是这么做的。
第二,为什么dish表里既要有price又要有stock?price是菜品价值属性,stock是库存约束属性。库存的存在,是为了让“售罄”这件事成为系统的硬约束,而不是靠前端按钮置灰来假装有约束。
第三,主键用什么策略?我的建议是订单表使用“时间戳+随机数”生成业务订单号(比如202406121530002345678),而不是直接用雪花ID或者自增ID。因为订单号要展示给用户,自增主键会暴露平台单量,纯雪花ID又太长。其他表正常用AUTO_INCREMENT或雪花ID都可以。
3.3 库存扣减、释放与售罄判断的SQL思路
库存处理是这类系统最值得写进论文、也最值得在答辩时展开的技术点。我先给出最简单正确的SQL写法。
下单时扣减库存的正确姿势,不是先查库存再UPDATE,而是一句带条件的UPDATE:
UPDATE dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity}这条SQL的作用是一步完成“判断库存是否够 + 库存扣减”,返回影响行数。影响行数为1说明扣减成功,为0说明库存不足或者菜品下架,直接抛业务异常“该菜品库存不足”。原理是MySQL行锁在UPDATE时对目标行加锁,配合stock >= #{quantity}条件,从根上杜绝了并发下超卖的可能。
用户取消订单或者超时未支付时,要释放库存:
UPDATE dish SET stock = stock + #{quantity} WHERE id = #{dishId}不要小看这个加回库存的动作,它必须和订单状态变更在同一个数据库事务里执行,否则会出现“订单取消了但库存没加回来”的数据不一致。这个点是我在联调时真实遇到过的Bug,后面会细讲。
4. SpringBoot核心业务实现:从用户提交订单到食堂出餐的全流程
数据库结构想清楚后,剩下的就是按照业务链路把接口一个个填进去。这一节我会把订单模块和库存联动这块的实现逻辑完整展开,这也是答辩时最能展示“你确实自己动手写过”的部分。
4.1 订单状态机:一张状态流转图就能让老师明白你不是在堆CRUD
订单状态不能设计成“下单/完成/取消”三个状态就草草了事,那样无法体现食堂接单出餐的中间过程。我设计了六个状态:
| 状态码 | 状态名 | 触发动作 |
|---|---|---|
| 0 | 待支付 | 用户提交订单后 |
| 1 | 已支付待接单 | 用户完成模拟支付后 |
| 2 | 制作中 | 食堂管理员点击“开始制作” |
| 3 | 已出餐待取餐 | 食堂管理员点击“出餐完成” |
| 4 | 已完成 | 用户点击“确认取餐”或系统自动完成 |
| 5 | 已取消 | 用户取消/超时未支付自动取消 |
这里有个设计细节:状态流转只能单向推进,写Service时要对“当前状态是否允许执行这个动作”做校验。比如一笔待支付订单不应该被点击“开始制作”,否则食堂端和用户端看到的信息就对不上了。
4.2 创建订单的核心逻辑:事务、锁与库存扣减的配合
创建订单是系统里最复杂的业务方法,同时涉及:校验菜品、计算总价、扣减库存、生成订单主表、生成订单明细。我贴一个简化版的Service核心代码:
@Override @Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(OrderCreateDTO dto) { // 1. 校验地址和购物车数据,获取购物车条目列表 List<CartItem> cartItems = cartItemMapper.selectList( new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, dto.getUserId()) .eq(CartItem::getStatus, 1)); if (CollectionUtils.isEmpty(cartItems)) { throw new BizException("购物车为空,无法下单"); } // 2. 计算总价,并检查每个菜品是否在售 BigDecimal totalAmount = new BigDecimal("0"); List<OrderItem> orderItems = new ArrayList<>(); for (CartItem cartItem : cartItems) { Dish dish = dishMapper.selectById(cartItem.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BizException("菜品[" + cartItem.getDishName() + "]已下架"); } // 扣减库存:UPDATE dish SET stock = stock - ? WHERE id = ? AND stock >= ? int rows = dishMapper.deductStock(dish.getId(), cartItem.getQuantity()); if (rows == 0) { throw new BizException("菜品[" + dish.getDishName() + "]库存不足"); } totalAmount = totalAmount.add(dish.getPrice().multiply( new BigDecimal(cartItem.getQuantity()))); // 组装订单明细快照 OrderItem item = new OrderItem(); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setDishImage(dish.getImage()); item.setPrice(dish.getPrice()); item.setQuantity(cartItem.getQuantity()); orderItems.add(item); } // 3. 生成订单主表 OrderInfo order = new OrderInfo(); order.setOrderNo(OrderNoGenerator.generate()); order.setUserId(dto.getUserId()); order.setCanteenId(dto.getCanteenId()); order.setTotalAmount(totalAmount); order.setStatus(0); orderInfoMapper.insert(order); // 4. 保存订单明细 for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 5. 清空购物车 cartItemMapper.delete(new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, dto.getUserId())); return order; }这里最核心的是@Transactional(rollbackFor = Exception.class)。为什么必须加事务?因为扣库存、插入订单主表、插入订单明细、清空购物车这四个操作必须要么全部成功、要么全部回滚。如果扣了库存但订单生成失败,库存就会凭空减少;如果订单生成了但库存没扣,就会超卖。事务就是给这四个操作上了“同一根绳”。
4.3 并发场景下如何防止超卖:分布式锁和数据库条件更新
如果只有单机单库,UPDATE ... WHERE stock >= ?这个条件更新其实已经能防住超卖了,因为MySQL的行锁会让并发的扣减操作串行执行。那为什么还要用Redis分布式锁?
因为我们的项目里不仅有“扣库存”,还有“同一用户同时下单防重复提交”“食堂管理员并发接单”等场景。Redis分布式锁可以把“判断购物车非空→计算总价→扣库存→生成订单”这段关键路径在分布式环境下串行化,进一步降低由于业务编排导致的并发bug概率。
我用的Redis锁是围绕StringRedisTemplate封装的简单工具类:
public boolean tryLock(String key, String value, long timeout, TimeUnit unit) { Boolean flag = stringRedisTemplate.opsForValue() .setIfAbsent(key, value, timeout, unit); return Boolean.TRUE.equals(flag); } public void unlock(String key, String value) { String currentValue = stringRedisTemplate.opsForValue().get(key); if (value.equals(currentValue)) { stringRedisTemplate.delete(key); } }使用方式是在创建订单的方法上加锁:
String lockKey = "lock:order:create:" + userId; String lockValue = UUID.randomUUID().toString(); boolean locked = redisLockUtil.tryLock(lockKey, lockValue, 5, TimeUnit.SECONDS); if (!locked) { throw new BizException("您正在提交订单,请勿重复操作"); } try { // 创建订单的主流程代码 } finally { redisLockUtil.unlock(lockKey, lockValue); }这个设计虽然简单,但足够在毕设场景下撑住并发演示。答辩时有老师问“你用什么解决并发超卖”,你可以从两个层面回答:数据库层用条件更新做兜底,应用层用Redis锁防止重复提交和业务串行化。
4.4 模拟支付与超时未支付订单处理
真实对接支付宝/微信支付在毕设里不是必须的,但完全不涉及支付,业务链条又会断在“提交订单”这一步。我的做法是做一个模拟支付接口:前端点击“确认支付”时,后端模拟延时200毫秒,然后把订单状态从0改为1,并记录支付时间。这个接口前端的交互是完整真实的。
还有一个必须处理的场景:用户下单后一直不支付怎么办?如果不处理,这些待支付订单会一直占着库存,导致其他用户买不到菜。我引入了定时任务,每1分钟扫描一次,把创建时间超过15分钟且状态为0的订单自动取消,并把库存加回:
@Component public class OrderTimeoutTask { @Autowired private OrderInfoMapper orderInfoMapper; @Autowired private DishMapper dishMapper; @Scheduled(cron = "0 * * * * ?") public void releaseTimeoutOrders() { List<OrderInfo> timeoutOrders = orderInfoMapper.selectList( new LambdaQueryWrapper<OrderInfo>() .eq(OrderInfo::getStatus, 0) .lt(OrderInfo::getCreateTime, LocalDateTime.now().minusMinutes(15))); for (OrderInfo order : timeoutOrders) { // 必须在事务里执行两张表的变更 orderInfoMapper.updateStatusById(order.getId(), 5); List<OrderItem> items = orderItemMapper.selectList(...); for (OrderItem item : items) { dishMapper.releaseStock(item.getDishId(), item.getQuantity()); } } } }这里的重点就是我在3.3节提到的:取消订单和释放库存必须在一个事务里,要么一起成功,要么一起失败。
4.5 食堂端的接单出餐流转
食堂端管理员的界面相对简单,但逻辑上有个常见误区:很多人把“接单”设计成点一下就把状态直接改成“已完成”。我拆成了两步:“开始制作”和“出餐完成”,中间状态是“制作中”。为什么要拆?因为业务直觉是——食堂不可能瞬间做好一份餐,制作是一个过程。拆出中间状态,才能支撑用户端展示“您的餐品正在制作中”这种提示,也让整个订单状态机更完整。
对应的两个接口:
- 接单接口:校验当前状态为1,更新为2。
- 出餐接口:校验当前状态为2,更新为3。
用户端“确认取餐”的动作,则是把状态从3更新为4。这样一条业务流下来,每个角色都有事做,每笔订单的状态变化都有明确的操作者,答辩演示时可以一步一步点给老师看。
5. 实际开发中踩过的坑,以及完整的排查思路
每个SpringBoot项目都会遇到类似的坑,我这里把印象最深的几个记录下来,每一个我都给出了排查链路而不是直接甩结论。
5.1 超卖问题:不是“用不用锁”的问题,而是“什么时候加锁”的问题
我第一版写创建订单时,库存扣减用的是“先SELECT stock,再判断,再UPDATE”的老三段写法:
Dish dish = dishMapper.selectById(dishId); if (dish.getStock() >= quantity) { dishMapper.updateStock(dishId, dish.getStock() - quantity); }自测时单用户下单完全没问题,直到我用JMeter模拟50个并发用户同时抢购同一道限量菜品,发现数据库里的库存变成了负数。排查链路是这样走的:
- 复现:用JMeter压测创建订单接口,50线程并发,循环5次,监控dish表的stock字段。
- 定位SQL日志:发现大量的
selectById和updateById交错执行,多个线程读到相同的库存值比如20。 - 找原因:SELECT和UPDATE是两个独立操作,线程A和线程B同时查到20,然后A扣减成19,B也扣减成19,但真实库存应该只剩18。这就是经典的“读改写”竞态条件。
- 修复:把“查库存+扣库存”合并成一条SQL:
UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},并检查受影响行数。 - 验证:重新压测,库存不再为负,超卖解决。
这个坑的教训是:并发问题一定要用并发的手段去验证,用单线程自测是测不出来的。
5.2 事务失效:同一个类里调用自己的方法,@Transactional不管用
还有一次我在调试一个用户下单后“扣减积分”功能时,发现积分扣了但订单回滚后买的东西居然还在。后来在日志里看到异常信息是"Transaction rolled back, but something went wrong",才反应过来事务没生效。
排查链路:
- 加日志,确认异常后看数据,发现订单没了但库存扣了,说明事务没有覆盖到“扣库存”这个动作。
- 看代码结构,发现问题出在
OrderServiceImpl里的createOrder()方法,它调用了一个同类里的私有方法deductStockAndGenerateItems(),这个方法加了@Transactional。 - 查Spring AOP原理:Spring的声明式事务是基于代理实现的。外部调用
createOrder()时,经过的是Spring生成的代理对象,事务能正常拦截;但类内部this.deductStockAndGenerateItems()这种调用,走的是this原对象,没有经过代理,注解自然不生效。 - 修复:把涉及多表变更的方法拆到另一个Service类中,由Spring注入代理对象再调用;或者把
@Transactional统一加在对外入口方法createOrder()上,让整个业务方法共享同一个事务。
这个坑在答辩时几乎必问,建议每个做SpringBoot项目的同学都去把自己的Controller、Service理一遍,看看有没有“自调用”导致事务失效的情况。
5.3 前端联调时最典型的跨域问题
前后端分离后,前端跑在http://localhost:5173,后端跑在http://localhost:8080,浏览器直接请求会被同源策略拦截。我一开始在Controller上加了@CrossOrigin,单个接口能通,但写了一大堆注解实在难看。后来统一用了全局配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个细节:如果allowCredentials(true)和allowedOrigins("*")一起使用,浏览器会直接报错。必须使用allowedOriginPatterns("*")才能兼顾。这个小坑不知道坑了多少人,写在这里给大家提个醒。
5.4 上传图片后无法访问的误区
菜品图片上传,很多同学第一反应是传到项目根目录下的static文件夹。我试过,结果发现重新打包部署后图片全部丢失,因为static在jar包里面,图片明明写进去了却没权限访问。
正确的做法是:在服务器或本机指定一个独立目录存放上传文件,比如/data/upload/,然后通过配置类映射静态资源路径到本地磁盘:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }浏览器访问http://localhost:8080/upload/20240612/xxx.jpg就能正常显示图片。application.yml里配置file.upload-dir: /data/upload,部署时只需确认这个目录存在,和代码jar包分离,后续升级发布不受影响。
6. 答辩时的加分项、常见追问,以及毕设做完之后的扩展方向
项目代码写完不代表事情结束,答辩才是把工作量“讲”出来的关键一环。
6.1 老师大概率会问的四个问题,提前准备好
| 老师常问问题 | 参考答案思路 |
|---|---|
| 为什么订单要在明细表里冗余菜品名和价格? | 避免菜品信息变更后历史订单显示错乱,保证订单是下单时的快照 |
| 并发下如何防止重复下单和库存超卖? | 数据库条件更新UPDATE ... WHERE stock >= ?保证原子性;Redis锁防止用户重复提交;事务保证扣库存和生成订单的一致性 |
| 用户取消订单后,库存什么时候加回来? | 取消或超时未支付时,在同一个事务里改订单状态并执行库存释放SQL |
| 系统后期要支持多食堂,怎么改? | 现有的表已经设计了canteen_id字段,菜品、订单都挂食堂维度,只需要新增食堂管理页面和权限隔离即可 |
6.2 建议做但很多同学忽略的“软件工程”加分项
除了功能跑通,有几个动作能让项目质量上一个档次:
- 用knife4j生成接口文档,答辩时演示API调试页面比直接贴代码直观得多。
- 写一份《需求分析说明书》和《数据库设计说明书》,论文直接有素材。
- 把JMeter压测结果、Redis锁的验证过程截图存档,说明你有用工程手段验证过问题。
6.3 毕业设计之外的延伸:它可以从课程项目变成简历项目
做完这个系统之后,我最大的感受是:它不是一个写完就丢的作业,而是能继续长出功能的东西。如果你想让它更有竞争力,可以考虑下面几个方向:
- 把模拟支付替换成接入支付宝沙箱支付,体验真实签名与回调流程。
- 引入消息队列(比如RocketMQ)处理订单超时和食堂出餐通知,实现削峰填谷。
- 加一个基于WebSocket的实时食堂窗口排队叫号功能,进一步体现技术的实时交互能力。
- 用Spring Security或Sa-Token做更细粒度的权限控制,比如食堂管理员只能操作自己食堂的数据。
根据我个人的经验,做这类系统有一个通用心法:每一次“有一个吸引人的业务点”出现,都要逼自己想一想底层逻辑是什么。比如食堂排队的问题,表面是排队,本质是资源调度;订单状态的设计,表面是字段,本质是业务状态机。把这些想明白了,不仅仅是毕设能拿高分,以后在真实工作中做企业级项目时,遇到订单系统、餐饮食堂系统、库存系统,你都会发现很多原则是相通的。
最后再分享一个小技巧:整个项目开发过程中,要养成每改一个Bug就写一段Bug记录的习惯,写清楚现象、排查步骤、根因、解决方案。我的毕业论文里的“系统测试与调试”一章,几乎就是靠这份记录撑起来的,不用临时抱佛脚。真的,做毕设最怕的不是代码有Bug,而是说不出Bug背后的原因。你有了这些底料,答辩的时候就踏实了。