简介:基于Java开发的食堂订餐与打单系统设计源码,适合计算机相关专业学生、Java初学者及餐饮管理系统开发者,用于模拟食堂订餐流程,解决订单创建、打单输出与日常运营管理等实际问题。整个压缩包约107KB,共25个文件,其中10个Java源文件承载核心业务逻辑,4个XML配置文件管理数据库连接与系统参数,3个SQL脚本负责数据表结构初始化,3个JFreeChart图形文件用于统计结果可视化,同时包含Markdown文档、属性文件、Git忽略文件及开源协议。已有245人学习下载,具备一定参考热度。通过学习该项目,可完整看到从数据建模、后端编码到配置文件编写、图表展示的闭环实现;源码目录划分清晰,适合在课程设计或毕业设计中直接借鉴,也能根据食堂、园区等不同场景二次扩展菜品分类、订单查询与报表导出等功能。
1. 基于 Java 的食堂订餐与打单系统,难在哪
前端点餐页面做起来很快,难的是订单进入后厨那条链路。用户在前端点完菜、支付完成,食堂后厨必须立刻得到一张可读的小票,上面按顺序列清楚桌号、菜品、数量和做法备注;打印丢了或者格式乱了,整个堂食节奏就断掉。所以一套合格的食堂订餐打单系统,本质上是「订单状态机 + 打印队列 + 打印机指令」三块的设计,而不是菜单 CRUD。
和普通电商订单相比,食堂场景少运费和售后,却多了出票、叫号、并单和退菜。比如一个用户下了两笔订单,后厨希望能按桌台合并成一张小票;午市高峰期 30 秒内有 10 单同时进来,订单编号、库存扣减和打印去重都要扛得住并发压力。这个标题里的“设计源码”,对应的是一套能直接看库表、看代码、跑打印的最小可运行项目。
如果你正在做 Java 基础阶段的项目,或想把 Spring 事务、MyBatis-Plus、异步任务、Socket 网络编程串成一条完整业务线,食堂订餐打单是很好的练手场景。面试聊订单状态机、幂等设计和打印异常恢复,也比单纯背一道 Java 八股文更有说服力。
2. 数据结构先行:食堂订餐打单的核心表与状态字段设计
设计数据库前需要明确一件事:订单表负责业务事实,打印队列负责动作事件。因为打印行为有失败和重试,如果把“是否打印”只做成订单的一个字段,失败记录没法重试,也无法统计打印机故障率。常见做法是把订单主表、订单明细、菜品库存、打印队列拆成四张表,让订单状态和打印状态各走各的状态机,再用一个订单号做关联。
2.1 四张核心表怎么分,先想明白再建库
| 表名 | 核心字段 | 职责 | 备注 |
|---|---|---|---|
| order_main | order_no、table_no、take_no、status、total_amount | 记录订单主事实 | 订单号唯一,状态有明确阈值 |
| order_item | order_no、dish_id、dish_name、qty、price、remark | 记录下单时的菜品快照 | 菜品改名不能影响小票 |
| dish | dish_id、name、price、stock | 菜品与库存 | 扣库存时用乐观锁 |
| print_queue | id、order_no、raw_data、status、retry_count | 待打印指令与重试状态 | 与订单表通过 order_no 关联 |
订单明细里为什么存dish_name快照而不是打印时再关联菜品表?因为后厨小票要以用户下单那一刻的信息为准。如果菜品改名、下架或价格调整,再去查菜品表可能出现名称对不上、金额对不上的问题。打单系统里“快照”是基本设计原则,下单字段和展示字段要分离。
2.2 建表 SQL:把订单号和幂等约束先钉死
CREATE TABLE `order_main` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '对外订单号', `table_no` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '桌号,自取时为空', `take_no` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '取餐号', `status` TINYINT NOT NULL DEFAULT 10 COMMENT '10待支付 20已支付 30已打印 40制作中 50已完成', `total_amount` DECIMAL(10, 2) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `print_queue` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `raw_data` MEDIUMBLOB NOT NULL COMMENT 'ESC/POS 指令快照', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待打印 1成功 2失败 3重试中', `retry_count` INT NOT NULL DEFAULT 0, `last_error` VARCHAR(255) NOT NULL DEFAULT '', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `print_time` DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_no和print_queue.order_no都建议建唯一索引。order_no是多方消息交互的凭证,支付回调、打印回执、用户查询状态都靠它;print_queue里同一订单只应存在一条“待打印”记录,重复插入就说明业务链路有重复触发。raw_data用 BLOB 存打印机指令,不要在重试时再拼接一次模板。
2.3 MyBatis-Plus 下的 Mapper 与查询写法
用 Java 做这类管理型系统,现在常见选型是 Spring Boot + MyBatis-Plus,原因不在于它比手写 XML 性能好,而是把常规 CRUD 收敛到BaseMapper,业务代码只写状态流转和特殊查询。想理解这一点,直接去看 MyBatis 源码里MapperProxy到SqlSession的调用链:接口方法在运行时被代理,最终落到SqlSession的select/update上,BaseMapper只是帮你把常用 SQL 提前生成好了。
@Mapper public interface OrderMainMapper extends BaseMapper<OrderMain> { } // 查询一笔已支付但未打印的订单 OrderMain order = orderMainMapper.selectOne( new LambdaQueryWrapper<OrderMain>() .eq(OrderMain::getOrderNo, orderNo) .eq(OrderMain::getStatus, 20) );.eq(OrderMain::getStatus, 20)使用 Lambda 引用避免硬编码列名。日常简单查询用 Wrapper 没问题,但出现多表联查或复杂统计时不要硬拼 Wrapper,直接写@Select注解或单独 XML,SQL 更容易 review 和维护。
2.4 状态机的阈值字段:订单状态与打印状态分开维护
订单状态码建议用 10 的倍数而不是 1、2、3,这样后续插入“待接单”“待出餐”等中间状态时不用改旧状态值。打印状态则是独立的:0 待打印、1 成功、2 失败、3 重试中。两个状态机的推进方向也有讲究,订单只能从 10 到 20 再到 30,不能从 30 退回 10;打印失败则只影响打印状态到 2,不影响订单进入已支付后的其他操作。
已支付之后的“取消”不能直接把订单状态改成 99。食堂场景下,付款后取消必然伴随退款和退菜两个动作,退款不到账就把库存加回去,会有对账风险。正确做法是先走退款单,退款成功后再把库存回补并更新状态。
3. Java 后端核心实现:订单创建、支付回调和打单任务串联
3.1 创建订单的并发控制链路
下单接口的第一个挑战是并发。午市高峰期同一道菜可能被同时点走最后三份,如果先select再update,两个请求都会读到库存 3,最后实际卖出 6 份。常见做法是直接在一条 SQL 里做条件扣减:
@Service public class OrderServiceImpl implements OrderService { @Resource private DishMapper dishMapper; @Resource private OrderMainMapper orderMainMapper; @Override @Transactional(rollbackFor = Exception.class) public String createOrder(CreateOrderRequest req) { // 幂等:同一终端重复提交时,返回原订单号 Long repeatId = repeatSubmitGuard.checkAndGet(req.getClientToken()); if (repeatId != null) { return orderMainMapper.selectById(repeatId).getOrderNo(); } for (CartItem item : req.getItems()) { // UPDATE dish SET stock = stock - #{qty} // WHERE id = #{dishId} AND stock >= #{qty} int rows = dishMapper.deductStock(item.getDishId(), item.getQty()); if (rows == 0) { throw new BizException("菜品库存不足: " + item.getDishId()); } } String orderNo = generateOrderNo(); OrderMain order = OrderMain.pending(orderNo, req.getTableNo(), req.getItems()); orderMainMapper.insert(order); return orderNo; } }@Transactional(rollbackFor = Exception.class)保证库存扣减和订单落库同生共死,任何一步抛异常整笔回滚。deductStock里的stock >= #{qty}条件让数据库来完成并发判断,返回 0 说明当前可用库存不够。重复提交的幂等保护,我一般会用 Redis 的SETNX key value EX 5实现,5 秒内同一clientToken直接返回原订单号。
generateOrderNo()不要用数据库自增主键暴露给用户,建议yyyyMMddHHmmss + 4 位随机数。这里如果有人在取餐号上直接用 Redis 的increment(),注意 key 必须是一个干净的数字字符串,一旦 key 被其他流程写入非数字内容,就会报increment() 不是 integer or out of range,这也是 Redis 使用里很常见的坑。
3.2 支付回调与打印触发:事务边界怎么切
支付回调是异步进来的,可能会因为网络重试被系统多次调用。所以回调处理的第一步是幂等更新:
public void payNotify(String orderNo) { // UPDATE order_main SET status = 20 // WHERE order_no = #{orderNo} AND status = 10 int rows = orderMainMapper.markPaid(orderNo); if (rows == 1) { byte[] ticket = ticketBuilder.buildFor(orderNo); printQueueMapper.insert(PrintTask.pending(orderNo, ticket)); } }rows == 1表示这次回调完成了“待支付”到“已支付”的状态跃迁,只有跃迁成功才生成打印任务。重复回调因为status = 10条件不满足,返回 0,不会重复向打印队列插入数据。这里不要把 Socket 打印放在事务方法里,打印机 IO 抖动会让支付回调阻塞几秒;先把打印指令落到print_queue,后台异步任务再消费它。
3.3 打单模块:Socket 写 9100 端口的 ESC/POS 指令
网络热敏打印机默认监听 9100 端口,Java 用 Socket 直连即可,不需要装厂商 SDK。小票内容要用 ESC/POS 指令控制,不然没有对齐、走纸和切纸能力:
public byte[] buildTicket(OrderDTO order) { StringBuilder sb = new StringBuilder(); sb.append("\u001B@"); // ESC @ 初始化打印机 sb.append("\u001Ba\u0001"); // ESC a 1 居中对齐 sb.append("幸福食堂取餐单\n"); sb.append("\u001Ba\u0000"); // ESC a 0 恢复左对齐 sb.append("单号: ").append(order.getOrderNo()).append("\n"); sb.append("桌号: ").append(order.getTableNo()).append("\n"); sb.append("----------------------------\n"); order.getItems().forEach(item -> { sb.append(String.format("%-10s x%-2d %6.2f\n", shorten(item.getName(), 10), item.getQty(), item.getPrice())); }); sb.append("----------------------------\n"); sb.append(String.format("合计: %.2f\n", order.getTotalAmount())); sb.append("\u001Bd\u0003"); // ESC d 3 走纸 3 行 sb.append("\u001DV\u0042"); // GS V B 切纸 return sb.toString().getBytes("GBK"); }\u001B@是初始化命令,每次打印前最好带上,清掉上一条任务残留的对齐状态。\u001Ba\u0001和\u001Ba\u0000是居中和左对齐开关。%-10s表示菜名占 10 个半角字符,58mm 纸整行约 32 个半角字符,80mm 纸约 42 个,超过这个宽度打印机会自动折行,模板里的数字要按实际纸宽调。
public PrintResult print(String ip, int port, byte[] data, int timeoutMs) { try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(ip, port), 2000); socket.setSoTimeout(3000); OutputStream out = socket.getOutputStream(); out.write(data); out.flush(); // 需要查询状态时,先发 DLE EOT 1 再读一字节 // 不同型号返回值不同,0x12/0x16 的语义以打印机手册为准 return PrintResult.success(); } catch (IOException e) { return PrintResult.fail(e.getMessage()); } }中文编码必须与打印机固件匹配。大多数网口热敏打印机默认按 GBK/GB18030 解释字符,Java 端用getBytes("GBK")发出;如果直接用 UTF-8,后厨小票会出现典型乱码。Socket 连接超时设 2 秒、读超时设 3 秒,避免打印机离线时请求线程被拖死。
3.4 打印失败与重试队列
打印任务入队后,由@Scheduled定时任务消费:
@Scheduled(fixedDelay = 1000) public void pollPrintQueue() { List<PrintTask> tasks = printQueueMapper.selectList( new LambdaQueryWrapper<PrintTask>() .in(PrintTask::getStatus, 0, 3) .lt(PrintTask::getRetryCount, 5) .orderByAsc(PrintTask::getId) .last("limit 5")); for (PrintTask task : tasks) { PrintResult result = printClient.print(printerIp, 9100, task.getRawData()); if (result.isOk()) { printQueueMapper.markSuccess(task.getId()); } else { printQueueMapper.markRetry(task.getId(), result.getError()); } } }status取 0 和 3,代表新任务和需要重试的任务;retry_count限制单任务最多试 5 次。.last("limit 5")是 MyBatis-Plus 的拼 SQL 写法,这里用来控制每轮最多拉 5 条,防止打印机故障时线程被消息淹没。超过重试上限后,把status置为 2,再通过告警消息通知运维或店长去检查打印机。
提示:
print_queue.raw_data建议存指令快照,重试时直接取 BLOB,不要在重试时重新拼接模板,避免订单状态变化影响小票内容。
4. 前端点餐 H5、后厨大屏与部署参数设置
4.1 用 Vue 3 搭一个可以提交订单的点餐页
后端接口约定好之后,前端页面可以极简。核心接口就三个:
| 接口 | 方法与路径 | 说明 |
|---|---|---|
| 菜单列表 | GET /api/menu/list | 返回菜品和售价 |
| 提交订单 | POST /api/order/create | 提交桌号和购物车 |
| 查询状态 | GET /api/order/status/{orderNo} | 返回订单状态码 |
<script setup> import { ref } from 'vue'; import axios from 'axios'; const pollTimer = ref(null); const submitOrder = async (items) => { const resp = await axios.post('/api/order/create', { tableNo: currentTable.value, items, clientToken: localStorage.getItem('clientToken') || crypto.randomUUID() }); localStorage.setItem('clientToken', crypto.randomUUID()); startPolling(resp.data.orderNo); }; const startPolling = (orderNo) => { pollTimer.value = setInterval(async () => { const { data } = await axios.get(`/api/order/status/${orderNo}`); if (data.status >= 40) { clearInterval(pollTimer.value); playPickupSound(); } }, 3000); }; </script>clientToken在首次进入页面时生成一次,提交成功后立刻更换,这样用户快速连点“提交”也只会创建一笔订单。轮询间隔 3 秒,对几十张桌的食堂完全够用;状态到达 40(制作中)就停止轮询并播放取餐提示音,不需要让前端一直请求。
4.2 WebSocket 还是轮询:后厨叫号选型怎么定
| 方案 | 实时性 | 实现成本 | 适用场景 |
|---|---|---|---|
| HTTP 轮询 | 3 秒内 | 极低,接口复用 | 小食堂、菜品不多、并发低 |
| WebSocket | 毫秒级 | 需要连接管理与心跳 | 大厅叫号屏、外卖窗口、高峰期并发高 |
食堂后厨的订单流转不是高频事件,3 秒轮询的服务端压力很小。WebSocket 真正的复杂度在断线重连、心跳保活和多端状态同步,小项目没必要为“看起来实时”付出这个成本。等后厨大屏、叫号屏、管理后台同时要接实时数据时,再统一走 WebSocket 网关不迟。
4.3 Nginx 反代、Druid 池和多环境 Profile 的参数清单
server { listen 80; root /opt/food/frontend/dist; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_connect_timeout 3s; } }proxy_pass后不带 URI,/api/order/create会原样转发到后端。前后端分离后,浏览器只访问 Nginx 一个源,跨域问题也就不存在了。
数据库连接池用 Druid 时,一套保守的初始参数是:
| 参数 | 值 | 说明 |
|---|---|---|
| initialSize | 5 | 启动时建立连接数 |
| minIdle | 5 | 最小空闲连接 |
| maxActive | 20 | 最大活跃连接 |
| maxWait | 60000 | 拿连接超时 60 秒 |
| validationQuery | SELECT 1 | 空闲检查语句 |
| testWhileIdle | true | 空闲时检测连接是否可用 |
maxActive=20对食堂订餐这种量级足够,调太大反而让 MySQL 在高峰期同时处理更多无效连接。环境区分用 Maven Profile 管理,不同 profile 注入不同db.url属性,避免把测试库地址带到生产配置里。
5. 打单乱码、缺纸与重复打印的高频故障排查
5.1 小票乱码先查这三处
| 排查点 | 常见问题 | 处理方式 |
|---|---|---|
| 字符编码 | Java 端用 UTF-8 写入 Socket | 改为getBytes("GBK") |
| 纸宽模板 | 菜名太长导致自动折行错位 | 按 58mm/80mm 重新设计列宽 |
| 控制命令 | 缺少 ESC @ 初始化,对齐状态残留 | 每次打印前补\u001B@ |
改了编码仍乱码时,用 Wireshark 抓 9100 端口的数据,看发出的第一个字节是不是1B 40;如果不是,说明指令在业务层被转成了字符串或经过了一层编码转换。
5.2 用一张 print_marker 表把“重打”关进幂等笼子
网络波动导致打印超时是很常见的,但打印机可能已经把票打出来了,此时若后台自动重试,后厨就会出两张一模一样的票。我一般会加一张print_marker表:
CREATE TABLE print_marker ( order_no VARCHAR(32) PRIMARY KEY, printed_at DATETIME NOT NULL, retried_at DATETIME NULL );流程是:打印任务成功送达打印机后插入print_marker;后台检查到 Socket 超时但打印机状态不可知时,不直接重打,而是查询print_marker是否已有记录且打印时间在 30 秒内,如果有就只提示“请后厨确认是否已出票”。重打接口必须带 30 秒时效 token,同时在retried_at记一笔,确保同一张单不会被点出三张小票。
5.3 定位“订单卡在已支付但后厨没出票”的 SQL
SELECT o.order_no, o.status, q.status AS print_status, q.retry_count, q.last_error FROM order_main o LEFT JOIN print_queue q ON o.order_no = q.order_no WHERE o.status = 20 AND (q.status = 2 OR q.status IS NULL);print_status为 2 时,直接看last_error,多数是 Socket timeout 或 connection refused;print_status为 NULL 时,说明打印任务根本没进队,要回到支付回调代码里检查markPaid返回行数是否为 1。
查询接口验证时,用一条 curl 就能看完整状态:
curl -s http://127.0.0.1:8080/api/order/status/202501011230001234 | jq .如果打印机 IP 是 DHCP 分配的,重启后地址变了,后厨端也会出现“打印失败”;更省心的做法是在路由器上给打印机绑定静态地址,并把printer_ip放到配置中心而不是写死在代码里。最后一个细节:重打按钮不要做成订单详情页的通用“再次打印”,而是读取print_queue中失败状态的任务,只对order_no对应的待重打任务重新进队列,配合print_marker的唯一键,后厨大屏上一键重打,既不会重复出票,也不会把已打印的小票再打一遍。
本文还有配套的精品资源,点击获取