资讯详情

Java食堂订餐打单系统实战:订单状态机与Socket打印设计

📅 2026/9/16 11:25:36 | 华诺云谱 👁 阅读
Java食堂订餐打单系统实战:订单状态机与Socket打印设计
简介基于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_mainorder_no、table_no、take_no、status、total_amount记录订单主事实订单号唯一状态有明确阈值order_itemorder_no、dish_id、dish_name、qty、price、remark记录下单时的菜品快照菜品改名不能影响小票dishdish_id、name、price、stock菜品与库存扣库存时用乐观锁print_queueid、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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 BaseMapperOrderMain { } // 查询一笔已支付但未打印的订单 OrderMain order orderMainMapper.selectOne( new LambdaQueryWrapperOrderMain() .eq(OrderMain::getOrderNo, orderNo) .eq(OrderMain::getStatus, 20) );.eq(OrderMain::getStatus, 20)使用 Lambda 引用避免硬编码列名。日常简单查询用 Wrapper 没问题但出现多表联查或复杂统计时不要硬拼 Wrapper直接写Select注解或单独 XMLSQL 更容易 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() { ListPrintTask tasks printQueueMapper.selectList( new LambdaQueryWrapperPrintTask() .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); }; /scriptclientToken在首次进入页面时生成一次提交成功后立刻更换这样用户快速连点“提交”也只会创建一笔订单。轮询间隔 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 时一套保守的初始参数是参数值说明initialSize5启动时建立连接数minIdle5最小空闲连接maxActive20最大活跃连接maxWait60000拿连接超时 60 秒validationQuerySELECT 1空闲检查语句testWhileIdletrue空闲时检测连接是否可用maxActive20对食堂订餐这种量级足够调太大反而让 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 定位“订单卡在已支付但后厨没出票”的 SQLSELECT 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 refusedprint_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的唯一键后厨大屏上一键重打既不会重复出票也不会把已打印的小票再打一遍。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。