基于JAVA的饭店点餐系统实战:Spring Boot+MyBatis从建表到状态机

发布时间:2026/10/29 22:13:57
基于JAVA的饭店点餐系统实战:Spring Boot+MyBatis从建表到状态机

简介:这是一份面向计算机专业学生与Java Web初学者、开发者的毕业设计论文文档,围绕基于Java的饭店点餐系统展开,可用于课程设计、论文写作参考或同类项目开发借鉴。压缩包内共1个doc文件,约2.31MB,内容为完整论文正文,涵盖摘要、绪论、开发技术介绍及系统设计实现等章节。论文采用B/S体系结构,以Java为开发语言、MySQL存储数据、Tomcat作WEB服务器,开发工具为MyEclipse,系统角色分为用户与管理员,主要模块包括菜品明细管理、订单管理、菜品管理、厨师管理、菜品分类管理、餐桌座位管理和管理员管理,并着重讨论了安全性、可扩展性、易用性与实时性等设计考量。目前已有153人学习下载,适合需要参考完整论文结构、功能模块划分与开发技术选型的读者,可帮助快速理解点餐系统的设计思路与实现过程。

1. 从一份“饭店点餐系统”文档说起:JAVA 做餐饮系统到底在做什么

很多人第一次接触“基于 JAVA 的饭店点餐系统”这个题目,是在毕业设计或者课程设计阶段。打开文档一看,需求写得挺全:桌台管理、菜品分类、下单、结账、后厨打印。但真到动手写代码,问题就来了——数据库表怎么设计?订单状态怎么流转?并发下单会不会把同一桌的菜算重?这份文档背后其实是一套完整的餐饮业务闭环,不是简单的增删改查堆砌。

我做过几个类似的项目,也帮人排查过不少“跑不起来”的点餐系统。核心结论是:JAVA 做点餐系统的技术栈已经非常成熟,Spring Boot + MyBatis 是主流选择,前端可以用 Vue 或者 Thymeleaf 快速出页面。真正难的不是框架,而是把“桌台—订单—菜品—支付”这四个实体的状态机理清楚。适合谁看?正在做课程设计的学生、想接私活的后端新手、以及需要快速搭一套内部点餐工具的小团队。下面我把这套系统的选型、建表、核心接口和踩坑点拆开讲,你照着能跑通一个最小可用版本。

2. 技术选型与数据库设计:为什么用 Spring Boot + MyBatis 而不是别的

2.1 后端框架选型:Spring Boot 的自动配置省掉了多少事

做饭店点餐系统,第一件事是选后端框架。常见做法是 Spring Boot + MyBatis,原因很直接:Spring Boot 内嵌 Tomcat,打成 jar 包就能跑,不需要单独装 Web 服务器;MyBatis 对 SQL 的控制比 JPA 更细,餐饮系统里经常要写多表关联查询(比如查某桌所有已下单菜品),手写 SQL 反而更顺手。

如果你用纯 Servlet + JDBC,光是连接池、事务管理、JSON 序列化就要写一堆样板代码。Spring Boot 把这些都自动配好了,你只需要在application.yml里写几行数据库连接信息。下面是一个最小可用的配置:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.restaurant.entity

这段配置里,serverTimezone=Asia/Shanghai必须加,否则 MySQL 8 会报时区错误。mapper-locations告诉 MyBatis 去哪里找 XML 映射文件。type-aliases-package让实体类在 XML 里可以直接写类名,不用写全限定名。

提示:如果你用的是 MySQL 5.7,驱动类可以写com.mysql.jdbc.Driver,但 MySQL 8 必须用com.mysql.cj.jdbc.Driver,否则启动报错。

选型理由再补一句:Spring Boot 的生态里,Spring Security 可以做权限控制(比如只有收银员能结账),Spring Task 可以做定时任务(比如每晚自动清理未结账的临时订单)。这些在餐饮系统里都是刚需,用其他框架要自己集成,成本高很多。

2.2 数据库表设计:四张核心表撑起整个点餐流程

餐饮系统的表不用多,但字段要想清楚。我一般会建这四张核心表:t_table(桌台)、t_category(菜品分类)、t_dish(菜品)、t_order(订单)和t_order_item(订单明细)。下面给出建表 SQL,你可以直接复制到 MySQL 里执行:

CREATE TABLE t_table ( id INT PRIMARY KEY AUTO_INCREMENT, table_no VARCHAR(10) NOT NULL UNIQUE COMMENT '桌号', capacity INT DEFAULT 4 COMMENT '座位数', status TINYINT DEFAULT 0 COMMENT '0空闲 1占用 2预订' ); CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, sort_order INT DEFAULT 0 ); CREATE TABLE t_dish ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, image_url VARCHAR(200), status TINYINT DEFAULT 1 COMMENT '1上架 0下架', INDEX idx_category (category_id) ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, table_id INT NOT NULL, total_amount DECIMAL(10,2) DEFAULT 0, status TINYINT DEFAULT 0 COMMENT '0待下单 1已下单 2已结账 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, INDEX idx_table (table_id), INDEX idx_status (status) ); CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, dish_id INT NOT NULL, dish_name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT DEFAULT 1, INDEX idx_order (order_id) );

这里有几个设计细节值得说。t_order_item里冗余了dish_name和price,因为菜品可能会改价或下架,订单明细必须保留当时的价格,否则对账会乱。t_order的status字段是整条业务线的核心,后面所有接口都要围绕它做状态判断。t_table的status和t_order的status要联动:下单时把桌台置为占用,结账时置回空闲。

注意:order_no不要用自增 ID 直接拼,建议用时间戳加随机数,避免被猜到订单量。常见做法是yyyyMMddHHmmss加 4 位随机数。

2.3 实体类与 Mapper 的对应关系

建完表之后,用 MyBatis 的逆向工程或者手写实体类。我一般手写,因为字段不多,而且能控制命名。以Order为例:

public class Order { private Integer id; private String orderNo; private Integer tableId; private BigDecimal totalAmount; private Integer status; private Date createTime; private Date payTime; // getter/setter 省略 }

Mapper 接口里定义方法,XML 里写 SQL。比如查某桌的未结账订单:

<select id="selectActiveOrderByTable" resultType="Order"> SELECT * FROM t_order WHERE table_id = #{tableId} AND status IN (0, 1) ORDER BY create_time DESC LIMIT 1 </select>

这个查询在点餐场景里调用频率很高:服务员点开某桌,要先看有没有未完成的订单,有就继续加菜,没有就新建。status IN (0, 1)表示待下单和已下单都算活跃订单,已结账和已取消不算。

3. 核心接口实现:从开台到结账的完整代码路径

3.1 开台与点餐接口:如何避免同一桌重复下单

开台逻辑很简单:把桌台状态改成占用,同时创建一条status=0的订单。但这里有个并发问题——两个服务员同时给同一桌开台,会创建两条订单。解决办法是在t_order表上加唯一索引,或者用数据库行锁。

我一般用后者,在 Service 层加@Transactional,然后先SELECT ... FOR UPDATE锁住桌台记录:

@Transactional public Order openTable(Integer tableId) { Table table = tableMapper.selectByIdForUpdate(tableId); if (table.getStatus() != 0) { throw new BusinessException("桌台已被占用"); } table.setStatus(1); tableMapper.updateById(table); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setTableId(tableId); order.setStatus(0); order.setTotalAmount(BigDecimal.ZERO); orderMapper.insert(order); return order; }

selectByIdForUpdate对应的 SQL 是SELECT * FROM t_table WHERE id = #{id} FOR UPDATE。这样第二个请求会阻塞,直到第一个事务提交,然后发现桌台状态不是 0,直接抛异常。这是最稳妥的做法,比应用层加锁可靠。

点餐接口是往t_order_item里插记录,同时更新t_order的总金额。注意不要每次插一条就更新一次总金额,而是批量插入后重算:

@Transactional public void addItems(Integer orderId, List<OrderItem> items) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != 0) { throw new BusinessException("订单已提交,不能加菜"); } for (OrderItem item : items) { item.setOrderId(orderId); orderItemMapper.insert(item); } BigDecimal total = orderItemMapper.sumPriceByOrderId(orderId); order.setTotalAmount(total); orderMapper.updateById(order); }

sumPriceByOrderId在 XML 里写SELECT SUM(price * quantity) FROM t_order_item WHERE order_id = #{orderId}。这样每次加菜后总金额都是准的,不会因为并发插入算错。

3.2 订单状态流转:下单、结账、取消的边界条件

订单状态从 0 到 1 是“下单”,从 1 到 2 是“结账”,从 0 或 1 到 3 是“取消”。每个流转都要校验前置状态,否则会出现已结账的订单又被取消这种脏数据。

下单接口:

public void submitOrder(Integer orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != 0) { throw new BusinessException("当前状态不可下单"); } order.setStatus(1); orderMapper.updateById(order); }

结账接口要同时改订单状态和桌台状态:

@Transactional public void checkout(Integer orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != 1) { throw new BusinessException("订单未下单或已结账"); } order.setStatus(2); order.setPayTime(new Date()); orderMapper.updateById(order); Table table = tableMapper.selectById(order.getTableId()); table.setStatus(0); tableMapper.updateById(table); }

取消接口类似,但要注意:如果订单已经结账,绝对不能取消。另外取消后桌台要释放,否则桌台会一直显示占用。

提示:状态流转建议用枚举类管理,不要直接写 0、1、2、3。枚举里可以加一个canTransferTo方法,把合法流转路径写死,Service 层调用时统一校验。

3.3 后厨打印与小票:一个容易被忽略的落地细节

很多课程设计文档里不写后厨打印,但实际饭店里这是刚需。实现方式有两种:一是调用本地打印机 SDK,二是生成 ESC/POS 指令通过网口发给打印机。我一般用后者,因为不依赖特定操作系统。

核心代码是拼指令:

public byte[] buildTicket(Order order, List<OrderItem> items) { ByteArrayOutputStream out = new ByteArrayOutputStream(); try { out.write(new byte[]{0x1B, 0x40}); // 初始化打印机 out.write(("桌号:" + order.getTableId() + "\n").getBytes("GBK")); out.write(("订单号:" + order.getOrderNo() + "\n").getBytes("GBK")); out.write("------------------------\n".getBytes("GBK")); for (OrderItem item : items) { String line = item.getDishName() + " x" + item.getQuantity() + "\n"; out.write(line.getBytes("GBK")); } out.write("------------------------\n".getBytes("GBK")); out.write(("合计:" + order.getTotalAmount() + "元\n").getBytes("GBK")); out.write(new byte[]{0x1D, 0x56, 0x00}); // 切纸 } catch (IOException e) { throw new RuntimeException("打印指令生成失败", e); } return out.toByteArray(); }

0x1B, 0x40是 ESC/POS 的初始化指令,0x1D, 0x56, 0x00是切纸指令。中文必须用 GBK 编码,UTF-8 打出来是乱码。这段代码生成字节数组后,通过 Socket 发给打印机 IP 的 9100 端口即可。

注意:不同品牌打印机指令集有差异,先拿一台测试机试。如果打印出来是乱码,先检查编码,再检查指令集是否兼容。

4. 避坑与排查:点餐系统上线前必须过的五道坎

4.1 坑一:订单金额算错,多半是并发插入导致的

现象:两个服务员同时给同一桌加菜,最后订单总金额比实际少了某道菜的钱。

原因:addItems方法里先插明细再重算总金额,两个事务交叉执行时,后算的那个可能没读到另一个事务刚插入的记录。

解决:把sumPriceByOrderId的查询放在同一个事务里,并且给t_order加行锁。或者更简单——在t_order_item上加唯一索引(order_id, dish_id),插入时用INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity),这样不会重复插入,总金额重算也准。

4.2 坑二:桌台状态和订单状态不同步

现象:订单已结账,但桌台还显示占用,新客人来了没法开台。

原因:结账接口里改了订单状态,但改桌台状态时抛了异常,事务回滚不完整。或者取消订单时忘了释放桌台。

解决:把订单状态变更和桌台状态变更放在同一个@Transactional方法里,任何一步失败都整体回滚。另外加一个定时任务,每 10 分钟扫描一次t_order里status=2但t_table.status=1的记录,自动修复。

4.3 坑三:MyBatis 返回 null 导致空指针

现象:查某桌活跃订单时返回 null,Service 层直接调用order.getStatus()报 NPE。

原因:selectActiveOrderByTable在没查到记录时返回 null,而不是空对象。

解决:在 Service 层做判空,或者用Optional包装。我一般写一个getActiveOrderOrThrow方法,查不到就抛业务异常,让上层处理。

4.4 坑四:MySQL 时区配置错误导致时间差 8 小时

现象:订单创建时间比实际时间少 8 小时,对账时发现日期不对。

原因:JDBC URL 里没加serverTimezone,或者加了但值不对。

解决:URL 里写serverTimezone=Asia/Shanghai,同时检查 MySQL 全局时区SHOW VARIABLES LIKE '%time_zone%'。如果 MySQL 用的是 UTC,插入时间会按 UTC 存,读出来再转上海时区就差了 8 小时。最稳妥的做法是数据库和 JDBC 都用上海时区。

4.5 坑五:前端传参类型不匹配导致 400 错误

现象:点餐接口报 400 Bad Request,日志里看不到具体原因。

原因:前端传的quantity是字符串 "2",后端实体类里是Integer,Spring 反序列化失败。

解决:在实体类字段上加@JsonFormat或者用@RequestBody接收时开启DeserializationFeature.ACCEPT_STRING_AS_NUMBER。更简单的做法是前端统一用JSON.stringify传数字类型,不要传字符串。

5. 进阶技巧:用状态机 + 乐观锁把订单流转做扎实

前面讲的订单状态流转是用 if-else 判断的,代码量一多就容易漏。我后来改成状态机 + 乐观锁,稳定性提升明显。具体做法是:在t_order表加一个version字段,每次更新时带上版本号,更新失败就重试或抛异常。

ALTER TABLE t_order ADD COLUMN version INT DEFAULT 0;

更新语句改成:

<update id="updateStatusWithVersion"> UPDATE t_order SET status = #{newStatus}, version = version + 1 WHERE id = #{id} AND status = #{expectedStatus} AND version = #{version} </update>

Service 层这样调用:

public void transferStatus(Integer orderId, OrderStatus expected, OrderStatus target) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != expected.getCode()) { throw new BusinessException("状态不匹配"); } int rows = orderMapper.updateStatusWithVersion( orderId, target.getCode(), expected.getCode(), order.getVersion()); if (rows == 0) { throw new BusinessException("并发冲突,请重试"); } }

这样即使两个请求同时结账,只有一个能成功,另一个会抛异常,不会出现重复结账。状态机枚举里定义合法流转:

public enum OrderStatus { PENDING(0), SUBMITTED(1), PAID(2), CANCELLED(3); // 省略构造器和 getCode public boolean canTransferTo(OrderStatus target) { if (this == PENDING && target == SUBMITTED) return true; if (this == SUBMITTED && target == PAID) return true; if ((this == PENDING || this == SUBMITTED) && target == CANCELLED) return true; return false; } }

验证方法很简单:写一个单元测试,模拟两个线程同时调用transferStatus,断言只有一个成功。我一般用CountDownLatch起 10 个线程,跑 100 次,看有没有重复结账。这个测试跑通,订单模块基本就稳了。

最后说个血泪经验:别在订单表上直接删记录。取消订单就改状态,数据留着对账用。我见过有人为了“干净”直接DELETE,结果月底对账发现少了几千块,查都没法查。数据库表设计时多留一个status字段,比事后后悔强。希望帮到你。

本文还有配套的精品资源,点击获取