简介:这是一套面向高校计算机相关专业学生的毕业设计/课程设计完整项目——溢香园餐饮管理系统,同时包含Web管理端与Android客户端,适合需要完成毕设选题、积累全栈开发经验或参考餐饮行业信息化方案的学习者。系统覆盖用户管理、菜品管理、订单与支付、库存监控、报表分析、评论评分等核心业务模块,并涉及前后端分离、RESTful API、JWT身份验证、数据加密及云服务器部署等工程实践。压缩包共960个文件,约77.01MB,以367个png与141个gif界面素材、125个java源码、89个xml布局、37个jsp页面及35个js、35个css等前端资源为主,另含sql脚本、jar依赖与说明文档,便于直接运行与二次开发。目前已有126人学习下载。读者可从中获得完整的双端项目结构、数据库设计思路、接口划分方式与安全编码参考,是理解软件开发生命周期、提升实际开发能力的实用案例。
1. 溢香园餐饮管理系统:一个双端毕设到底能跑通哪些真实业务
很多计算机毕业设计选题看着热闹,真到答辩演示就露馅:点菜页面能打开,后厨不出单,库存不扣减,会员积分对不上。溢香园餐饮管理系统(web端+Android端)这个题目之所以值得做,是因为它天然包含两条真实业务链——顾客扫码点餐到后厨出单,以及后台采购入库到库存预警。这两条链跑通,你的毕设就不是“能演示”,而是“能解释每一分钱怎么算出来的”。
这个系统适合三类人:软件工程毕业设计想拿高分的同学,需要一套能讲清架构的完整项目;物联网工程毕业设计想加硬件联动的同学,可以把后厨打印机或取餐叫号屏接进来;网络工程毕业设计想练部署的同学,可以把它拆成前后端分离加数据库主从。核心不是界面多漂亮,而是订单状态机、库存扣减时机、双端数据一致性这三个点你能不能讲明白。下面按“先立住模型,再动手复现,最后避坑”的顺序拆开讲。
2. 先定业务模型:订单状态机与库存扣减时机怎么设计
2.1 从“点餐”到“出单”的状态流转
餐饮系统的核心不是增删改查,是状态流转。顾客在 Android 端下单,订单不是直接变成“已完成”,中间要经过待支付、已支付、已接单、制作中、待取餐、已完成,异常时还有已取消和已退款。很多同学把状态写成一个status字段随便改,最后对账时发现“已取消”的订单扣了库存却没回滚。
我一般会画一张状态迁移表,把每个状态允许的下一跳写死,代码里用枚举加校验,而不是散落在各个 Service 里。下面是一个最小可用的状态机定义,用 Java 枚举实现,Android 端和 Web 端共用同一套状态码。
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付"), ACCEPTED(2, "已接单"), COOKING(3, "制作中"), READY(4, "待取餐"), FINISHED(5, "已完成"), CANCELLED(6, "已取消"), REFUNDED(7, "已退款"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } // 校验状态迁移是否合法,非法迁移直接抛异常,避免脏数据 public static boolean canTransfer(OrderStatus from, OrderStatus to) { switch (from) { case PENDING_PAY: return to == PAID || to == CANCELLED; case PAID: return to == ACCEPTED || to == REFUNDED; case ACCEPTED: return to == COOKING || to == CANCELLED; case COOKING: return to == READY; case READY: return to == FINISHED; default: return false; } } }这段代码的关键在canTransfer方法:它把业务规则收拢到一个地方,Web 端后台改状态、Android 端取消订单,都调这个方法。参数from和to是枚举实例,不是数据库里的数字,避免有人直接传 5 导致越权跳转。常见翻车点是“已支付”直接跳到“已完成”,跳过接单和制作,后厨根本没收到单,但库存已经扣了。
2.2 库存扣减的三个时机与选型理由
库存扣减时机是餐饮系统最容易扯皮的地方。常见做法有三种:下单即扣、支付成功扣、出单完成扣。下单即扣能防止超卖,但顾客取消后要回滚,回滚逻辑写不好就出现“库存虚减”;支付成功扣比较折中,但支付回调有延迟,高并发时仍可能超卖;出单完成扣对库存最准,但高峰期可能已经卖超了才提示。
我一般会选“支付成功扣减 + 取消回滚”,并在数据库层加乐观锁。具体做法是库存表加version字段,扣减时带版本号更新,更新影响行数为 0 就重试或报错。下面是对应的 SQL 和 Java 调用片段。
-- 库存表结构,version 用于乐观锁 CREATE TABLE `ingredient_stock` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `ingredient_id` BIGINT NOT NULL, `quantity` DECIMAL(10,2) NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0, UNIQUE KEY `uk_ingredient` (`ingredient_id`) ); -- 扣减库存,只有版本号匹配才更新 UPDATE ingredient_stock SET quantity = quantity - #{amount}, version = version + 1 WHERE ingredient_id = #{ingredientId} AND version = #{version} AND quantity >= #{amount};// Service 层调用,失败时重试一次 public boolean deductStock(Long ingredientId, BigDecimal amount) { for (int i = 0; i < 2; i++) { IngredientStock stock = stockMapper.selectByIngredientId(ingredientId); if (stock.getQuantity().compareTo(amount) < 0) { throw new BizException("库存不足"); } int rows = stockMapper.deduct(ingredientId, amount, stock.getVersion()); if (rows > 0) { return true; } } return false; }参数说明:amount是本次扣减数量,version从查询结果里取,quantity >= amount是数据库层的兜底,防止并发时扣成负数。重试两次是为了应对乐观锁冲突,如果两次都失败说明并发很高,直接抛异常让上层处理。注意不要用SELECT ... FOR UPDATE锁整行,餐饮高峰期会把数据库连接池打满。
2.3 双端数据一致性:Web 后台与 Android 端怎么同步
Web 端和 Android 端共用一套后端接口,但两端对实时性要求不同。Android 端顾客下单后要立刻看到状态变化,Web 端后厨要立刻收到新订单。常见做法是 Android 端轮询,Web 端用 WebSocket 推送。轮询间隔设 3 到 5 秒,太短耗电,太长顾客以为没点上。
我一般会在后端加一个订单事件表,每次状态变更写一条记录,Android 端拉取增量事件,Web 端通过 WebSocket 广播。这样即使 WebSocket 断了,Android 端也能靠轮询补上。下面是一个简化的 WebSocket 广播代码,用 Spring 的SimpMessagingTemplate。
// 订单状态变更后广播给后厨端 @Autowired private SimpMessagingTemplate messagingTemplate; public void broadcastOrderChange(OrderEvent event) { // 目的地 /topic/kitchen 是后厨端订阅的频道 messagingTemplate.convertAndSend("/topic/kitchen", event); // 同时写入事件表,供 Android 端增量拉取 orderEventMapper.insert(event); }参数说明:/topic/kitchen是 WebSocket 目的地,后厨端用 STOMP 订阅;event里包含订单号、旧状态、新状态、时间戳。注意不要广播全量订单,只广播变更事件,否则数据量大时前端会卡。Android 端拉取增量时用lastEventId做游标,避免重复处理。
3. 动手复现:从数据库到双端接口的最小闭环
3.1 数据库建表与初始化数据
先把核心表建出来,不要一上来就搞几十张表。最小闭环只需要五张:菜品表、订单表、订单明细表、库存表、库存流水表。菜品表存菜名和价格,订单表存状态和总价,订单明细存每道菜的数量,库存表存当前库存,库存流水存每次扣减和回滚记录,方便对账。
CREATE TABLE `dish` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `category` VARCHAR(32) DEFAULT NULL, `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架' ); CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `table_no` VARCHAR(16) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0, `total_amount` DECIMAL(10,2) NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `dish_id` BIGINT NOT NULL, `quantity` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL ); CREATE TABLE `stock_flow` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `ingredient_id` BIGINT NOT NULL, `change_amount` DECIMAL(10,2) NOT NULL, `type` TINYINT NOT NULL COMMENT '1扣减 2回滚', `order_no` VARCHAR(32) DEFAULT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP );初始化数据至少插 10 道菜和 5 种原料,原料和菜品要有关联关系,比如“宫保鸡丁”对应鸡肉和花生。这个关联关系可以放在菜品表的扩展字段里,也可以单独建一张配方表。我一般会建配方表,因为一道菜可能对应多种原料,扣减时要按配方比例算。
3.2 后端接口:下单、支付回调、库存扣减
后端用 Spring Boot 起一个单体服务,先不拆微服务。下单接口接收菜品列表,生成订单号和订单明细,状态设为待支付。支付回调接口收到支付成功通知后,把状态改成已支付,并触发库存扣减。库存扣减按配方表算出每种原料的用量,逐条扣减并写流水。
@PostMapping("/order/create") public Result createOrder(@RequestBody OrderCreateReq req) { // 1. 校验菜品是否上架 List<Dish> dishes = dishMapper.selectByIds(req.getDishIds()); // 2. 计算总价,服务端算价,不信任客户端传的价格 BigDecimal total = dishes.stream() .map(d -> d.getPrice().multiply(new BigDecimal(req.getQuantity(d.getId())))) .reduce(BigDecimal.ZERO, BigDecimal::add); // 3. 生成订单号和明细 String orderNo = "YX" + System.currentTimeMillis(); Order order = new Order(); order.setOrderNo(orderNo); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setTotalAmount(total); orderMapper.insert(order); // 4. 插入明细 for (Dish dish : dishes) { OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setDishId(dish.getId()); item.setQuantity(req.getQuantity(dish.getId())); item.setPrice(dish.getPrice()); orderItemMapper.insert(item); } return Result.ok(orderNo); }参数说明:req.getDishIds()是菜品 ID 列表,req.getQuantity(dishId)是每道菜的数量。关键点是总价在服务端算,客户端传的价格一律不用,否则有人改请求就能一分钱吃一顿。支付回调里再调deductStock,扣减失败要记录日志并人工介入,不能直接吞掉异常。
3.3 Android 端最小下单页面与网络层
Android 端不用做太复杂,一个菜品列表页加一个购物车页就够。网络层用 Retrofit 加 OkHttp,接口地址指向本机后端。注意 Android 9 以后默认不允许明文 HTTP,本地调试要在AndroidManifest.xml里加android:usesCleartextTraffic="true",或者配 HTTPS。
// Retrofit 接口定义 interface OrderApi { @POST("/order/create") suspend fun createOrder(@Body req: OrderCreateReq): Result<String> @GET("/order/status/{orderNo}") suspend fun queryStatus(@Path("orderNo") orderNo: String): Result<OrderStatusVO> } // 下单调用,协程里执行 viewModelScope.launch { val req = OrderCreateReq(dishIds, quantities) val result = orderApi.createOrder(req) if (result.code == 0) { // 下单成功,跳转到订单状态页,开始轮询 startPolling(result.data) } else { toast(result.msg) } }参数说明:dishIds和quantities是平行数组,quantities的下标对应dishIds的下标。轮询用viewModelScope加delay(3000),页面销毁时自动取消,避免内存泄漏。常见坑是轮询没取消,退出页面后还在请求,导致后端日志里一堆无效查询。
3.4 Web 后厨端:订单看板与出单操作
Web 后厨端用 Vue 或 React 都行,核心是一个订单看板,按状态分列显示。新订单进来时用 WebSocket 推送,看板自动刷新。后厨点“接单”调后端接口改状态,点“出单”再改一次。看板要显示桌号、菜品明细、下单时间,方便后厨按顺序做。
// WebSocket 连接与订阅 const socket = new SockJS('/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, () => { stompClient.subscribe('/topic/kitchen', (message) => { const event = JSON.parse(message.body); // 根据事件类型更新看板 if (event.type === 'ORDER_PAID') { addToPendingList(event.order); } else if (event.type === 'ORDER_CANCELLED') { removeFromList(event.orderNo); } }); });参数说明:/ws是 WebSocket 握手地址,/topic/kitchen是订阅频道。注意Stomp.over(socket)默认会打印调试日志,生产环境要关掉。看板更新时不要全量重绘,按订单号做 key 局部更新,否则订单多时页面会闪。
4. 避坑与排查:双端毕设最容易翻车的 5 个点
4.1 订单状态乱跳导致库存对不上
现象:后台看到订单从“已支付”直接变成“已完成”,库存扣了但后厨没出单。原因:状态迁移没校验,前端传什么状态就改什么状态。解决:所有状态变更走canTransfer校验,非法迁移返回错误码,并在日志里记录操作人和原状态。
4.2 库存扣减并发时扣成负数
现象:高峰期两个订单同时扣同一原料,库存从 1 扣成 -1。原因:先查后改,中间没有锁或版本号。解决:用乐观锁更新,WHERE quantity >= amount AND version = ?,更新影响行数为 0 就重试或报库存不足。
4.3 Android 端轮询把后端打挂
现象:演示时多个 Android 端同时轮询,后端 CPU 飙升。原因:轮询间隔太短,且没有做增量拉取。解决:间隔设 3 到 5 秒,用lastEventId拉增量,后端加缓存,相同订单号的状态查询走 Redis。
4.4 WebSocket 断线后后厨收不到新单
现象:后厨看板长时间不刷新,重启页面才恢复。原因:WebSocket 没有心跳和重连。解决:加心跳每 30 秒发一次,断线后指数退避重连,重连成功后拉一次全量未完成订单。
4.5 支付回调重复触发导致重复扣库存
现象:同一笔支付回调收到两次,库存扣了两次。原因:回调没有幂等处理。解决:用订单号加回调流水号做唯一索引,插入成功才处理业务,重复插入直接返回成功。
5. 进阶技巧:用对账脚本验证库存流水与订单一致性
系统跑起来后,怎么证明库存没算错?我一般会写一个对账脚本,每天凌晨跑一次,把当天的订单明细按配方展开成原料用量,和库存流水里的扣减记录做比对。不一致的订单号输出到文件,人工核查。这个脚本用 Python 写最快,直接连数据库查。
import pymysql from collections import defaultdict # 连接数据库 conn = pymysql.connect(host='localhost', user='root', password='', db='yixiangyuan') cursor = conn.cursor() # 查当天已支付订单的原料用量 cursor.execute(""" SELECT oi.order_id, r.ingredient_id, SUM(oi.quantity * r.amount) AS should_deduct FROM order_item oi JOIN recipe r ON oi.dish_id = r.dish_id JOIN orders o ON oi.order_id = o.id WHERE o.status IN (1,2,3,4,5) AND DATE(o.created_at) = CURDATE() GROUP BY oi.order_id, r.ingredient_id """) should = defaultdict(float) for order_id, ingredient_id, amount in cursor.fetchall(): should[(order_id, ingredient_id)] = float(amount) # 查库存流水里的实际扣减 cursor.execute(""" SELECT order_no, ingredient_id, SUM(change_amount) AS actual FROM stock_flow WHERE type = 1 AND DATE(created_at) = CURDATE() GROUP BY order_no, ingredient_id """) actual = defaultdict(float) for order_no, ingredient_id, amount in cursor.fetchall(): actual[(order_no, ingredient_id)] = float(amount) # 比对 for key, should_amount in should.items(): actual_amount = actual.get(key, 0.0) if abs(should_amount - actual_amount) > 0.01: print(f"不一致: 订单 {key[0]}, 原料 {key[1]}, 应扣 {should_amount}, 实扣 {actual_amount}") cursor.close() conn.close()参数说明:recipe表存菜品和原料的对应关系,amount是每份菜用的原料量。比对阈值设 0.01 是为了容忍浮点误差。这个脚本跑通后,答辩时你可以直接展示“系统能自证库存准确”,比说一百句“我做了库存管理”都有用。
我自己的习惯是每次改完库存逻辑,先跑一遍对账脚本,确认历史数据没被改坏,再提交代码。这个习惯帮我省了至少三次答辩前夜的紧急修数据。希望帮到你。
本文还有配套的精品资源,点击获取