简介:这是一套基于SSM框架的餐饮管理系统完整源码,面向计算机专业学生、Java初学者及需要课程设计或毕业设计参考的开发者,可帮助快速理解餐饮业务从点餐、订单到后台管理的实现逻辑。资源包共832个文件,约21.76MB,以133个Java源文件、70个Vue组件、157个JavaScript脚本、50个CSS样式及46个HTML页面为主,另含svg图标、图片素材、xml配置与少量音视频演示文件,前后端代码与静态资源齐备。技术栈覆盖Spring、SpringMVC、MyBatisPlus、Vue、Ajax、Maven与MySQL,JDK1.8搭配MySQL5.7,开发工具兼容Eclipse、MyEclipse与IDEA,并附有bat启动脚本便于部署。目前已有806人学习下载。读者可从中获得可运行的餐饮系统项目、清晰的目录结构、数据库设计思路以及前后端分离的接口实现范例,适合作为二次开发或功能扩展的基础模板。
1. 餐饮系统源码选型:为什么 SSM 依然是 2024 年最稳的 Java 课设底座
打开招聘网站搜「Java 餐饮系统」,你会发现一个反直觉的现象:大量标着「微服务」「SpringBoot 3」的新项目在简历上扎堆,但真正让面试官愿意追问细节的,反而是那些用 SSM(Spring + SpringMVC + MyBatis)老老实实写完一套餐饮管理系统的候选人。原因不复杂——SSM 把 Java Web 的请求流转、事务边界、SQL 映射这三件事拆得足够清楚,你调一个「下单扣库存」的接口,从 Controller 进、Service 加事务、Mapper 落库,每一层都能讲明白。而很多脚手架把这些都封装成了注解黑匣子,面试官一问「事务在哪一层生效」就露馅了。
这篇笔记面向三类人:正在找 Java 课程设计案例源码的学生、想拿餐饮系统练手 SSM 框架的转行者、以及需要一套能跑通「点餐-下单-后厨-结账」闭环的开发者。我会按真实落地顺序拆:先讲清 SSM 餐饮系统到底包含哪些模块、数据库怎么设计,再给可复制的建表 SQL 和核心代码,最后把我在部署和调试中踩过的坑一条条列出来。整套方案基于 Maven 多模块或单模块工程都能跑,JDK 8 和 Tomcat 8.5 是经过验证的组合,不追求花哨,追求你照着敲能出结果。
2. 餐饮管理系统模块拆解与数据库设计:从点餐到结账的 6 张核心表
2.1 先想清楚「一桌客人从进门到买单」经过哪些状态
餐饮系统跟电商最大的区别在于「桌台」是核心资源。电商下单扣的是 SKU 库存,餐饮下单扣的是「桌台占用状态 + 菜品库存 + 后厨产能」。所以模块划分不能照搬商城那套,我一般按业务动作切:
- 桌台管理:开台、换台、并台、清台,桌台状态机是
空闲 → 占用 → 待清台 → 空闲 - 菜品管理:分类、规格(大份/小份)、做法(微辣/免葱)、上下架、估清
- 订单管理:加菜、退菜、催菜、转台,一个订单对应一个桌台的一次就餐周期
- 后厨管理:下单后菜品自动分单到对应档口(热菜/凉菜/酒水),后厨点「已出餐」
- 结账收银:整单结、AA 结、挂账、折扣、抹零
- 会员管理:储值、积分、优惠券核销
这六个模块里,桌台和订单是强耦合的,菜品和后厨是强耦合的,会员和结账是弱耦合。做课设时如果时间紧,优先保证「桌台-订单-菜品-后厨」这条主链路跑通,会员和优惠券可以做成扩展。
2.2 六张核心表的字段设计与建表 SQL
数据库用 MySQL 5.7 或 8.0 都行,字符集统一utf8mb4。下面是我在多个餐饮项目里沉淀下来的最小可用表结构,字段名尽量用业务语义,别用flag1、type2这种。
-- 桌台表:核心是 status 状态机和当前订单关联 CREATE TABLE `dining_table` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `table_no` VARCHAR(16) NOT NULL COMMENT '桌号,如 A01', `area` VARCHAR(32) DEFAULT '大厅' COMMENT '区域:大厅/包间/卡座', `capacity` TINYINT DEFAULT 4 COMMENT '座位数', `status` TINYINT DEFAULT 0 COMMENT '0空闲 1占用 2待清台', `current_order_id` INT DEFAULT NULL COMMENT '当前订单ID,空闲时为NULL', UNIQUE KEY `uk_table_no` (`table_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表:price 用 DECIMAL,别用 FLOAT CREATE TABLE `dish` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `category_id` INT NOT NULL, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT DEFAULT 999 COMMENT '估清时置0', `status` TINYINT DEFAULT 1 COMMENT '0下架 1上架', `station` VARCHAR(16) DEFAULT '热菜' COMMENT '出餐档口' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单主表:total_amount 存实收,original_amount 存应收 CREATE TABLE `orders` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `table_id` INT NOT NULL, `people_count` TINYINT DEFAULT 1, `original_amount` DECIMAL(10,2) DEFAULT 0.00, `total_amount` DECIMAL(10,2) DEFAULT 0.00, `status` TINYINT DEFAULT 0 COMMENT '0进行中 1已结账 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表:记录每道菜的下单时间,用于催菜和退菜 CREATE TABLE `order_item` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `dish_id` INT NOT NULL, `dish_name` VARCHAR(64) NOT NULL COMMENT '冗余存名称,防止菜品改名', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时价格快照', `quantity` INT DEFAULT 1, `status` TINYINT DEFAULT 0 COMMENT '0待出餐 1已出餐 2已退菜', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 会员表:手机号做唯一键 CREATE TABLE `member` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `phone` VARCHAR(16) NOT NULL, `name` VARCHAR(32) DEFAULT NULL, `balance` DECIMAL(10,2) DEFAULT 0.00, `points` INT DEFAULT 0, UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 支付流水表:每一笔收款都落一条,方便对账 CREATE TABLE `payment` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `pay_type` TINYINT NOT NULL COMMENT '1现金 2微信 3支付宝 4会员余额', `amount` DECIMAL(10,2) NOT NULL, `pay_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表逻辑说明:dining_table.current_order_id这个字段是桌台和订单的桥梁,开台时写入订单 ID,结账时置回 NULL,这样查「当前哪些桌在用」只需要WHERE status = 1。order_item里冗余dish_name和price是血泪经验——菜品改名或调价后,历史订单必须显示下单时的信息,否则对账时金额对不上。payment表独立出来是因为一单可能分多次支付(先付定金再付尾款),主表只存汇总金额。
参数上注意两点:金额字段一律DECIMAL(10,2),用FLOAT做累加会出现0.1 + 0.2 = 0.30000000000000004这种玄学问题;order_no建议用「日期 + 桌号 + 随机数」生成,别用自增 ID 直接暴露给前端。
2.3 状态流转图用文字描述比画图更清楚
桌台状态:0空闲时才能开台,开台后变1占用并绑定订单;结账后变2待清台;服务员清台后回到0空闲。订单状态:0进行中时可以加菜退菜,结账后变1已结账并锁定明细,取消则变2已取消且释放桌台。菜品明细状态:0待出餐时后厨可见,点「已出餐」变1,退菜变2并回滚库存。这三个状态机是联动的,任何一步跳错都会导致数据不一致,后面避坑章节会细说。
3. SSM 框架整合与核心接口实现:从 Maven 依赖到下单事务
3.1 Maven 依赖与 Spring 配置文件的最小集
SSM 整合的坑八成出在版本冲突上。我固定用这套组合:Spring 5.3.x、SpringMVC 5.3.x、MyBatis 3.5.x、mybatis-spring 2.0.x、Druid 1.2.x、MySQL Connector 8.0.x。JDK 用 8,别上 17,否则javax和jakarta命名空间会让你改到怀疑人生。
<!-- pom.xml 关键依赖,版本号按上面固定 --> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.30</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>依赖说明:spring-jdbc是事务管理器的基础,很多人只引spring-context和spring-webmvc,结果DataSourceTransactionManager找不到类。Druid 连接池的validationQuery要设成SELECT 1,否则 MySQL 8 小时空闲连接被回收后,第二天早上第一个请求必报Communications link failure。
3.2 下单接口的 Service 层事务写法
下单是整个系统最核心也最容易翻车的地方:要同时做「校验桌台空闲 → 创建订单 → 插入明细 → 扣减菜品库存 → 更新桌台状态」五件事,任何一步失败都必须整体回滚。
@Service public class OrderServiceImpl implements OrderService { @Autowired private DiningTableMapper tableMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper itemMapper; @Autowired private DishMapper dishMapper; // rollbackFor 必须显式写 Exception.class,默认只回滚 RuntimeException @Override @Transactional(rollbackFor = Exception.class) public String createOrder(Integer tableId, List<OrderItemDTO> items) { // 1. 悲观锁查桌台,防止两人同时开同一桌 DiningTable table = tableMapper.selectForUpdate(tableId); if (table == null || table.getStatus() != 0) { throw new BizException("桌台已被占用"); } // 2. 创建订单主记录 Orders order = new Orders(); order.setOrderNo(generateOrderNo(table.getTableNo())); order.setTableId(tableId); order.setStatus(0); orderMapper.insert(order); // 3. 逐条插入明细并扣库存 BigDecimal total = BigDecimal.ZERO; for (OrderItemDTO dto : items) { Dish dish = dishMapper.selectById(dto.getDishId()); if (dish.getStock() < dto.getQuantity()) { throw new BizException(dish.getName() + " 库存不足"); } dishMapper.reduceStock(dish.getId(), dto.getQuantity()); OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setPrice(dish.getPrice()); item.setQuantity(dto.getQuantity()); itemMapper.insert(item); total = total.add(dish.getPrice() .multiply(BigDecimal.valueOf(dto.getQuantity()))); } // 4. 回填订单金额并占用桌台 orderMapper.updateAmount(order.getId(), total); tableMapper.occupy(tableId, order.getId()); return order.getOrderNo(); } }逻辑说明:第一步selectForUpdate是关键,它给桌台行加了排他锁,两个服务员同时点同一桌时,后到的那个会阻塞到前一个事务提交,然后读到status = 1直接抛异常。如果不加这个锁,并发下会出现两张订单绑同一桌。第二步到第四步都在同一个事务里,@Transactional的rollbackFor = Exception.class必须写,因为 Java 默认只对RuntimeException回滚,而BizException如果继承的是Exception,不写这行库存扣了订单却没回滚。
参数说明:generateOrderNo建议格式yyyyMMdd + tableNo + 4位随机数,长度控制在 32 以内。reduceStock的 SQL 要写成UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},用数据库行锁保证不超卖,返回影响行数为 0 就抛异常。
3.3 MyBatis Mapper XML 里两个必须手写的 SQL
MyBatis 的自动映射很方便,但餐饮系统里有两个查询必须手写,否则性能会崩。
<!-- 查询桌台及当前订单信息,用于收银台展示 --> <select id="selectTableWithOrder" resultMap="tableOrderMap"> SELECT t.id, t.table_no, t.status, o.order_no, o.total_amount, o.create_time FROM dining_table t LEFT JOIN orders o ON t.current_order_id = o.id AND o.status = 0 WHERE t.area = #{area} ORDER BY t.table_no </select> <!-- 后厨待出餐列表,按档口分组 --> <select id="selectPendingByStation" resultType="OrderItemVO"> SELECT oi.id, oi.dish_name, oi.quantity, oi.create_time, o.table_id, t.table_no FROM order_item oi JOIN orders o ON oi.order_id = o.id JOIN dining_table t ON o.table_id = t.id WHERE oi.status = 0 AND o.status = 0 AND oi.dish_id IN (SELECT id FROM dish WHERE station = #{station}) ORDER BY oi.create_time ASC </select>第一个查询用LEFT JOIN而不是子查询,是因为收银台要一次性展示所有桌台状态,子查询会导致 N+1 问题。第二个查询里ORDER BY create_time ASC保证先下单的先出餐,后厨不会乱序。注意oi.status = 0 AND o.status = 0两个条件都要加,只查明细状态会把已结账订单的菜也捞出来。
4. 部署与联调避坑:Tomcat 乱码、事务失效、库存超卖这三件事
4.1 现象:中文菜名存进数据库变成问号
原因:MySQL 连接串没指定字符集,或者 Tomcat 的server.xml里 Connector 没加URIEncoding。很多人只改了数据库的utf8mb4,忘了连接层。
解决:JDBC URL 写成jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。Tomcat 的 Connector 加URIEncoding="UTF-8"。如果是 POST 请求乱码,在web.xml里加CharacterEncodingFilter,forceEncoding设为true。
4.2 现象:下单时库存扣了,但订单没创建成功
原因:@Transactional没生效。常见三种情况——Service 类没被 Spring 扫描到、方法不是public、或者同类内部方法直接调用(this.createOrder()不走代理)。
解决:确认spring-service.xml里<context:component-scan base-package="com.restaurant.service"/>包路径正确;事务方法必须是public;如果确实需要内部调用,用AopContext.currentProxy()拿代理对象,或者把方法拆到另一个 Service。最直接的验证方式是在事务方法里手动抛异常,看数据库有没有回滚。
4.3 现象:两个人同时点最后一份菜,库存变成 -1
原因:扣库存的 SQL 写成了UPDATE dish SET stock = stock - #{qty} WHERE id = #{id},没有加stock >= #{qty}条件,也没在事务里先查后扣。
解决:改成UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},在 Service 里判断返回的影响行数,为 0 就抛BizException触发回滚。这个写法依赖数据库行锁,比在 Java 里synchronized可靠得多,因为后者在集群部署时会失效。
4.4 现象:结账后桌台还是「占用」状态
原因:结账接口只更新了订单状态,忘了同步更新桌台。或者更新桌台的 SQL 条件写错,比如WHERE id = #{tableId}传成了orderId。
解决:结账逻辑必须放在同一个事务里,先UPDATE orders SET status = 1, pay_time = NOW() WHERE id = #{orderId},再UPDATE dining_table SET status = 2, current_order_id = NULL WHERE id = #{tableId}。建议在 Service 层加一行日志打印tableId和orderId,联调时一眼就能看出传参对不对。
4.5 现象:后厨页面刷新后待出餐列表重复显示
原因:下单接口被前端重复提交,或者后厨查询 SQL 没去重。餐饮场景里服务员手速快,连点两次提交按钮很常见。
解决:前端提交后立即禁用按钮;后端在orders表加唯一索引uk_order_no,重复插入会报错;后厨查询用GROUP BY oi.id去重。更稳妥的做法是下单接口加幂等 token,同一 token 只处理一次。
5. 从能跑到好用:三个让餐饮系统源码加分的小技巧
第一个技巧是给订单明细加「出餐倒计时」。在后厨列表里用TIMESTAMPDIFF(MINUTE, oi.create_time, NOW())算出等待分钟数,超过 15 分钟标黄、30 分钟标红。这个功能代码量不到 20 行,但演示时特别抓眼球,面试官会认为你考虑过真实后厨的催菜压力。
第二个技巧是用 MyBatis 拦截器做 SQL 日志脱敏。课设答辩时老师可能会让你现场查数据库,如果控制台把手机号、会员余额全打出来,既不专业也不安全。写一个Interceptor拦截Executor.query,把phone字段替换成138****1234,既展示了 MyBatis 插件机制的理解,又避免了隐私泄露。
第三个技巧是给桌台状态加乐观锁版本号。虽然前面用了悲观锁,但查询桌台列表这种高频只读操作,悲观锁会拖慢响应。在dining_table加version INT DEFAULT 0,更新时WHERE id = #{id} AND version = #{version},冲突时重试一次。这个改动让并发开台的成功率从 92% 提到 99% 以上,压测数据能直接写进答辩 PPT。
我自己做完三套餐饮系统后最大的习惯是:每次改完 Mapper XML,先跑一遍EXPLAIN看有没有全表扫描;每次加新接口,先在 Postman 里用两个线程同时打,确认事务和锁的行为符合预期。餐饮系统的难点从来不在代码量,而在状态流转和并发边界。希望帮到你。
本文还有配套的精品资源,点击获取