Java食堂订餐打单系统实战:订单事务、并发扣库存与ESC/POS打印全解析
2026/9/24 19:25:18 网站建设 项目流程

简介:基于Java开发的食堂订餐与打单系统设计源码,面向校园食堂日常运营管理场景,适合正在学习Java Web开发、需要完成课程设计或希望了解订餐流程的开发者。资源共25个文件,压缩包约107KB,核心包含10个Java源文件,覆盖订餐、订单生成与打单处理等主要业务逻辑;另有XML配置文件、SQL数据库脚本和JFreeChart图形文件,分别承担组件配置、数据存储与统计可视化职责。系统还附带了文档说明、Git忽略规则、属性配置及开源许可文件,结构简洁且配置与代码分层清晰。从订单提交到打单输出形成完整闭环,JFreeChart图形文件可将统计数据直观呈现,便于运营者快速掌握食堂数据动态,适合功能演示、教学剖析和二次开发扩展。已有245人学习或下载,可作为理解Java工程组织、数据库脚本设计与图表集成实践的参考。

1. 一套能跑通食堂高峰期的Java订餐打单系统:它到底在解决什么

饭点食堂的窗口永远是两个战场:前面是排队打饭的队伍,后面是手写单子、算账、找零钱的窗口阿姨。用Java实现一套食堂订餐与打单系统源码,就是把这类手工作业变成两条自动闭环——用户提前下单、窗口自动打单、后厨按票做菜、取餐口报号出餐。这类项目在java课程设计案例源码里看着不算难:无非是增删改查加一张订单表。但真正按“能落地”的要求去做,订单状态流转、并发扣库存、打印小票不重不丢,每一环都有讲究。本文从表结构、核心下单接口、ESC/POS打单指令到高峰削峰,把做出一套能跑通完整流程的系统所需要的关键步骤拆开讲完,适合正在做Java课程设计、毕业设计,或者想用完整项目练手的人。

2. 从下单到出票的链路设计:订单与打印的数据地基

食堂订餐的逻辑看起来是“选菜、下单、出票”,但真正落地要拆成两条链路:订单链路是从用户提交到库存扣减完成、订单状态变为“已预订”;打单链路是从订单生成触发打印事件,到打印机把小票打出来、后厨按票做菜。缺了打单链路,这个项目就退化成一张订单表增删改查,面试官或老师一眼能看出你没做过真实方案;缺了订单链路,打单就成了一张无法追溯的纸条。所以设计源码的第一步不是写Controller,而是把这两条链路的数据库地基打对。

2.1 食堂订餐的业务闭环:两条链路缺一条都会翻车

订单链路里的核心是“库存”和“状态”。食堂的菜单往往是当天的,菜品表要维护上架状态和可售库存;用户提交订单时抢先扣库存,扣成功才返回下单成功,扣失败就直接提示“售罄”。这个动作放在下单接口的最前面,因为食堂场景没有购物车结算这种冗长流程,用户选完就下单,库存就应在那一刻被锁定。

打单链路的核心是“事件”和“记录”。订单落库后不是结束,而是开始——后厨需要一张小票。小票要包含订单号、窗口、菜品明细、备注,还要区分首次打印和补打,否则后厨会拿着重复的票做重复的菜。打印这个动作不能硬塞在下单事务里,因为打印机是外部设备,网络一抖动、纸一卡,订单事务就被拖住,甚至回滚。后面第3章和第6章会分别讲事务边界和异步打印,这里先记住:打单链路要的是“订单可靠生成后再去打印”,而不是“订单和打印同生共死”。

2.2 五张表把订餐和打单串起来:菜谱、订单、明细、打印记录

先给建表语句,再逐张说设计理由。第一版不需要建那么多冗余字段,够用、不乱才是重点。

-- 用户表:区分普通用户、窗口商家、食堂管理员 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1窗口商家 2管理员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 菜品表:食堂各窗口的菜单,带库存和上下架状态 CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, category VARCHAR(20) COMMENT '窗口/档口名', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 订单主表:一个订单对应一次订餐 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_price DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已预订 1已接单 2制作中 3已出餐 4已取消', remark VARCHAR(255), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 订单明细表:订单和菜品的多对多关系落点 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(50) NOT NULL, price DECIMAL(10, 2) NOT NULL, quantity INT NOT NULL ); -- 打印记录表:打单链路的追溯依据 CREATE TABLE print_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, print_type TINYINT NOT NULL DEFAULT 0 COMMENT '0下单打印 1补打', print_count INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 0 COMMENT '0成功 1失败', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );

sys_user和dish是基础档案,orders和order_item是订单核心,print_log是整个打单需求在数据库层的落点。订单主表里放order_no唯一索引,避免订单号重复;订单明细表冗余dish_name,是因为菜品改名后历史订单还要能显示当时的价格和名称,不能去关联一张随时会变的dish表。

print_log这张表许多课程设计源码里都没有,但它是“打单系统”和普通订餐系统最大的区别。每次打印都留一条记录,补打次数、打印类型、成败状态都在这张表里,后面第4章补打幂等、第5章打印排查都依赖它。没有这张表,补打就无法判断该不该再出票,后厨被重复票淹没只是时间问题。

2.3 订单状态机:一张表看清状态流转,代码里怎么落地

食堂订餐没有支付环节,核心状态流转从“已预订”开始。我一般把状态定义成枚举,放在源码的enums目录里,避免Service层到处散落魔法数字0、1、2、3。状态机的合法流转当如下表:

状态码含义允许进入的动作
0已预订用户取消(到4)、窗口接单(到1)
1已接单后厨开始制作(到2)、窗口拒单(到4)
2制作中出餐完成(到3)
3已出餐无,终态
4已取消无,终态
public enum OrderStatus { RESERVED(0, "已预订"), ACCEPTED(1, "已接单"), COOKING(2, "制作中"), FINISHED(3, "已出餐"), CANCELED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

使用枚举的好处是状态码和描述成对出现,写业务逻辑时不会把0和4搞混。我习惯把状态变更再收敛成一个方法:先查当前状态,再判断目标状态是否在合法流转表里,非法流转直接抛业务异常。比如已出餐的订单不允许再被取消,已取消的订单不允许再接单。这套校验放在领域层或Service层都可以,但一定要集中,不要在每个接口里各写一套if else,否则后期改状态规则时会漏改。

3. 下单接口的Java实现:事务边界、状态机与并发扣库存

下单接口是整个订餐系统的命门。它不只是“insert一条订单”,而是要把库存预扣、订单主表、订单明细、后续打印事件在一个事务边界里组织清楚。这一章的代码骨架可以直接搬到你的项目里,重点看事务边界怎么划、库存扣减怎么写、打印事件为什么不能放在事务内。

3.1 下单服务的核心Java代码:事务里只管数据,别管打印机

@Service public class OrderService { private final DishMapper dishMapper; private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final PrintService printService; public OrderService(DishMapper dishMapper, OrderMapper orderMapper, OrderItemMapper orderItemMapper, PrintService printService) { this.dishMapper = dishMapper; this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; this.printService = printService; } @Transactional(rollbackFor = Exception.class) public OrderVO submitOrder(OrderCreateDTO dto, Long userId) { // 1. 校验菜品存在且上架 List<Dish> dishes = dishMapper.selectByIds(dto.getDishIds()); if (dishes.isEmpty()) { throw new BizException("菜品不存在或已下架"); } // 2. 原子扣减库存,返回影响行数判断是否成功 for (OrderItemDTO item : dto.getItems()) { int updated = dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (updated == 0) { throw new BizException("菜品库存不足,下单失败"); } } // 3. 创建订单主表,初始状态为已预订 Order order = new Order(); order.setOrderNo(this.generateOrderNo()); order.setUserId(userId); order.setStatus(OrderStatus.RESERVED.getCode()); order.setRemark(dto.getRemark()); orderMapper.insert(order); // 4. 写入订单明细,冗余菜品快照 for (OrderItemDTO item : dto.getItems()) { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setDishId(item.getDishId()); orderItem.setDishName(item.getDishName()); orderItem.setPrice(item.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); } // 5. 事务提交后,再触发异步打单 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { printService.asyncPrint(order.getOrderNo()); } } ); return OrderVO.from(order); } }

这段代码的关键在事务边界:库存扣减、订单主表、订单明细三项数据操作必须同生共死,任何一个失败都回滚,不会出现“库存扣了但订单没生成”的情况。事务提交后通过TransactionSynchronizationManager注册回调,在afterCommit里触发打印,让打印网络IO完全不占事务时间。

参数设计上,dto里的items是下单菜品列表,dishId和quantity对应明细;userId从登录态获取,前端不能传。generateOrderNo生成订单号,常见做法是日期加随机数或雪花ID,注意订单号要唯一,orders表的order_no唯一索引就是兜底。rollbackFor=Exception.class必须写,因为Spring的@Transactional默认只回滚RuntimeException,业务异常BizException如果不是RuntimeException子类,检查异常抛出时事务不会回滚,订单就落库了。

3.2 扣库存的一条SQL:并发下不超卖的关键

扣库存最怕超卖:100份菜,200个人同时下单,最后库存变成负数。新手容易写成“先select查库存,在Java里减1,再update”,这个做法在并发场景下必然翻车,两个请求同时读到stock=5,各扣3份,库存变-1。需要靠数据库条件更新来兜底:

UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity} AND status = 1

这条SQL的原理是让数据库在行锁内做“读出当前值、比较、扣减”三步,WHERE条件里的stock >= #{quantity}充当乐观锁,如果当前库存不够,影响行数为0,业务代码抛出“库存不足”。这里不用悲观锁SELECT FOR UPDATE,是因为读多写少的食堂场景,条件更新已经足够,不必让所有下单请求排队等一把行锁。

扣库存必须在事务的最前面执行。先扣库存再写订单,能让库存不足的情况尽早暴露,避免先插入订单、最后扣库存失败回滚产生无效的ID自增和日志噪音。订单接口的响应时间也会更稳定,因为绝大多数失败请求在数据库UPDATE这一层就退出了。

3.3 事务边界再想一步:为什么打单要等事务提交后执行

把打印调用直接写在下单方法里,是这类源码最常见的翻车点。下单事务里带打印,订单库和打印机的命运被绑在一起:打印机网络超时,下单接口等3秒;打印机卡纸,订单直接回滚,用户再点一次又生成一张新订单但旧订单没打票,数据彻底乱了。

afterCommit回调让打印发生在事务提交之后,但这还不够,因为afterCommit还是同步执行。打印机是外部设备,它的响应时间不可控,同步调用仍然会让下单接口卡住。所以我一般在printService.asyncPrint里做两件事:把打印任务丢进一个有界队列,由单独消费者线程处理;或者直接注入一个线程池,异步执行打印逻辑。第6章会给出队列的具体代码,这里记住结论:事务内只做数据操作,事务提交后异步做硬件IO。

辨识一个打单系统做没做好的细节就在这里。如果下单接口里直接new Socket连打印机写数据,高峰期必堵;如果所有打印都通过异步队列串行消费,接口响应时间就和打印机状态完全解耦。

4. 打单系统的Java实现:ESC/POS指令生成与补打幂等

打单模块是整个系统的难点,也是标题里“打单系统”四个字真正对应的部分。它要处理的不是Java对象,而是一串打印机认识的二进制指令。这章先把选型说清楚,再给出一段能直接用在小票机上的字节流代码,最后讲补打接口怎么做幂等。

4.1 本地ESC/POS打印机还是云打印:选型看这三点

打单方案主要分两类:本地热敏打印机和云打印开放平台API。对单个食堂,我一般推荐本地ESC/POS打印机,原因很简单——食堂后厨环境相对封闭,打印机就在局域网里,不需要经过外部网络。

对比项本地ESC/POS打印机云打印开放平台API
接入方式Socket访问打印机IP和9100端口,发送二进制指令HTTP调用云端接口,云端推送到打印机
网络依赖只依赖食堂局域网依赖公网,断网则打不了单
部署成本一根网线或USB线,几百元需要联网、注册开发者账号、对接API
适合场景单食堂、单门店、局域网稳定多门店、连锁食堂、需要远程集中管理

云打印适合连锁食堂,总部要远程看各门店出单情况;单食堂项目用云打印反而增加故障点,公网抖动、平台接口变更都会让后厨打不出票。课程设计或毕业设计用本地打印机方案,还能把ESC/POS指令这一层知识讲清楚,比调一个HTTP接口更能体现源码功底。

4.2 用Java拼小票字节流:初始化、标题、明细、切纸

这款代码就是把订单对象翻译成打印机能识别的ESC/POS指令序列。小票机大多内置GBK中文字库,指令里会设置对齐、换行、切纸,拼接成一个byte数组后通过Socket发给打印机。

public byte[] buildTicket(Order order, List<OrderItem> items) throws Exception { ByteArrayOutputStream bos = new ByteArrayOutputStream(); // 0x1B 0x40 初始化打印机,清除上次状态 bos.write(new byte[]{0x1B, 0x40}); // 0x1B 0x61 0x01 居中标题 bos.write(new byte[]{0x1B, 0x61, 0x01}); bos.write("食堂订餐小票".getBytes("GBK")); bos.write(new byte[]{0x0A, 0x0A}); // 0x1B 0x61 0x00 左对齐正文 bos.write(new byte[]{0x1B, 0x61, 0x00}); bos.write(("订单号:" + order.getOrderNo()).getBytes("GBK")); bos.write(new byte[]{0x0A}); bos.write(("下单时间:" + order.getCreateTime()).getBytes("GBK")); bos.write(new byte[]{0x0A, 0x0A}); // 明细区:菜名 x数量 金额 for (OrderItem item : items) { String line = item.getDishName() + " x" + item.getQuantity() + " " + item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); bos.write(line.getBytes("GBK")); bos.write(new byte[]{0x0A}); } bos.write(new byte[]{0x0A}); if (order.getRemark() != null && !order.getRemark().isEmpty()) { bos.write(("备注:" + order.getRemark()).getBytes("GBK")); bos.write(new byte[]{0x0A, 0x0A}); } // 0x1D 0x56 0x42 0x00 走纸切纸 bos.write(new byte[]{0x1D, 0x56, 0x42, 0x00}); return bos.toByteArray(); }

这段代码里有三个参数值得一说。第一是编码必须用GBK,绝不能跟着后端统一用UTF-8,热敏打印机的中文指令大多按GBK字库解析,用UTF-8打出来就是“锟斤拷”。第二是0x1B 0x61后面的0x01和0x00分别代表居中和左对齐,对齐方式影响小票可读性。第三是0x1D 0x56 0x42 0x00是切纸指令,有的打印机切纸后还会多走一段白纸,具体走纸量要看打印机手册,课程设计里用全切指令即可。

发送小票的代码反而是最简单的,用TCP Socket连打印机9100端口:

try (Socket socket = new Socket(printerIp, 9100)) { OutputStream out = socket.getOutputStream(); out.write(ticket); out.flush(); }

printerIp是后厨打印机的局域网IP,9100是大多数热敏打印机默认支持的RAW打印端口。连接建立后把字节流原样丢出去,打印机就按内部解析器执行指令并出纸。注意Socket要放在try-with-resources里,打印完及时关闭,否则文件句柄会被占满。

4.3 补打接口怎么才不重票:SELECT FOR UPDATE加上限次数

补打的业务规则是:首单打印失败或小票丢失时,允许后厨重新打一次。但这个“一次”必须被数据库约束住,否则后厨连点三下,后厨操作台上就飘三张一模一样的票。补打代码要用悲观锁把打印记录锁住,再判断次数:

public void reprint(String orderNo) { // 悲观锁锁定该订单的打印记录,防止并发补打 PrintLog log = printLogMapper.selectForUpdateByOrderNo(orderNo); if (log != null && log.getPrintCount() >= 2) { throw new BizException("该订单已补打一次,请勿重复操作"); } // 生成补打记录,printType=1 表示补打 PrintLog newLog = new PrintLog(); newLog.setOrderNo(orderNo); newLog.setPrintType(1); newLog.setPrintCount(log == null ? 1 : log.getPrintCount() + 1); printLogMapper.insert(newLog); // 执行实际打印 printService.print(orderNo, PrintType.REPRINT); }

selectForUpdateByOrderNo对应的Mapper语句是SELECT * FROM print_log WHERE order_no = #{orderNo} FOR UPDATE,这会锁住该订单的打印记录行,直到事务结束。后厨连点补打时,第二个请求会在这一行上等待,第一个请求打印完成、事务提交后,第二个请求读到的是最新的printCount=2,直接抛出异常。

打印记录先落库、再触发打印。先打票再写log会出现打印成功、记录丢失的假失败,导致后厨误以为没打到又补一张,操作流程上要把“落库”作为补打动作完成的标志。

5. 常见问题与避坑记录:5个让后厨炸锅的真实场景

这类系统我前后写过两个版本,第一个版本给食堂用,被后厨阿姨追着骂过。遇到的坑集中在并发、编码、事务这三个方向。下面按“现象→原因→解决”列出来,都是可以直接对标自己代码排查的经验。

5.1 打印卡在事务里:账扣了、票没出,后厨来吵架

现象:用户下单后,系统提示下单成功,但后厨小票机一直空着;再看数据库,订单状态还是“已预订”,重试一次就多生成一单。

原因:下单方法里直接同步调了打印,打印机网络超时,数据库连接被事务占用,订单一直不提交,库存被预扣但无法释放。

解决:把打印调用从事务里挪出来,用TransactionSynchronizationManager的afterCommit回调,事务提交后再触发打印。同时给打印加上超时时间,Socket连接超时设2秒,读超时设3秒,不让打印机拖垮整个下单接口。

5.2 库存扣成负数:先查再扣的脏读翻车现场

现象:100份的红烧肉,高峰期卖出120单,库存字段变成负数,后厨炒不出来,订单还全都显示成功。

原因:先SELECT stock,在Java里判断库存够不够,再UPDATE。两个请求同时读stock=100,都在内存里减1,最后都执行UPDATE,数据库层没有二次校验。

解决:一条UPDATE解决问题,WHERE里带上stock >= #{quantity},由数据库在行锁内完成比较和扣减。影响行数为0就抛业务异常,前端提示“已售罄”。这条SQL前面3.2写的就是标准写法。

5.3 打印中文全乱码:小票上全是“锟斤拷”

现象:数字、英文、价格都正常,只有菜名是乱码,后厨拿着天书一样的票来找你。

原因:后端统一配置了UTF-8编码,生成的字节流也是UTF-8,而热敏打印机内置中文字库大多是GBK解析。UTF-8的菜名字节被GBK解码,中文自然全乱。

解决:打印字节流的拼接入口单独转换编码,所有写到打印机的字符串都用getBytes("GBK")。数据库、接口、页面继续用UTF-8,只有打印机出口做一次GBK转换,两套编码互不污染。

5.4 重复下单:前端禁了按钮,后端接口裸奔

现象:用户连点两次提交,订单生成了两笔,票打了两张,菜做了双份。

原因:前端在按钮上加了disabled,但只防正常用户,拦不住接口层重放。下单接口没有幂等校验,同一请求发两次就是两单。

解决:前端在进入下单页时生成一个requestId,随请求带上;后端在sys_user_order表或单独幂等表里记录requestId,插入时依赖唯一索引,冲突就返回第一次的订单结果。没有表结构改动的话,可以用Redis的SETNX做幂等,key是requestId,value是orderNo,过期时间设5分钟。

5.5 补打重票:接口没有幂等,后厨被票淹没

现象:小票丢了,后厨点了三次补打,打印机连着出三张一样的票。

原因:补打接口只做了“查订单→拼小票→发给打印机”,完全没记录“这单打过几次”。任何一次点击都是一次新的打印。

解决:给每个订单维护print_log表,补打前用SELECT FOR UPDATE锁住打印记录行,打印次数超过限制就拒绝。补打逻辑在4.3已经给出,核心是让打印动作有状态、可追溯。

6. 把打单从同步阻塞改成队列削峰:高峰期不再卡死

食堂11:30到12:15是订单峰值,如果每单都同步往打印机写网络数据,后厨打印机串口会排队,下单接口的响应时间会被拖到几秒。我一般会给打印单独加一个有界队列,下单接口只负责把订单号丢进队列,由后台单线程消费者串行取单、调用打印机。

@Component public class PrintTaskConsumer { private final BlockingQueue<String> queue = new LinkedBlockingQueue<>(500); private final PrintService printService; public PrintTaskConsumer(PrintService printService) { this.printService = printService; } @PostConstruct public void start() { new Thread(() -> { while (true) { try { String orderNo = queue.take(); printService.print(orderNo); } catch (Exception e) { // 单张票打失败不能影响后续订单,记录日志后继续 System.err.println("print failed: " + e.getMessage()); } } }, "print-consumer").start(); } public boolean offer(String orderNo) { return queue.offer(orderNo); } }

队列长度设置为500,超过这个值说明打印机已经堵死,再往后塞订单只会让内存堆积,这时要告警而不是无限排队。单机部署时这个方案够用,拆微服务后把LinkedBlockingQueue换成Redis Stream或MQ,消费者逻辑不变,只是队列换了个远端存储。

验证打单系统是否扛得住,最直接的方法是压测下单接口,先用curl确认接口能通:

curl -X POST http://localhost:8080/api/order/submit \ -H "Content-Type: application/json" \ -d '{"items":[{"dishId":1,"quantity":2}],"remark":"不要香菜"}'

再写一个并发脚本,20个线程同时提交100个订单,观察两点:下单接口的响应时间应该稳定在几十毫秒,不因打印机状态而波动;打印队列消费完后,print_log表里每个订单正好有一条成功记录,没有重复、没有缺失。如果接口时快时慢,检查是否还有别的代码在请求链路上同步碰了硬件。

调优上还有一个小技巧:把一整张小票的所有字节拼成一个byte[]一次性写入Socket输出流,不要一行一个write。打印机串口处理慢,多次小写入会频繁触发缓冲区刷新,增加整体耗时。把这个细节做掉后,我这边同一台打印机出票速度大约提升了三分之一。最早做这套系统时,我把打印调用硬塞进下单事务里,一个打印机卡纸,整张订单回滚,被后厨追着问为什么票没出来。从那以后,凡是涉及硬件IO的活,我都规规矩矩放到事务提交之后。这条经验比任何设计模式都值钱,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询