简介:这是一套基于Java开发的食堂订餐与打单系统源码,面向校园食堂运营管理人员、Java初学者及毕业设计开发者,可用于模拟订餐、订单生成和打单打印等日常流程。压缩包共25个文件,大小仅107KB,项目按Maven工程结构组织,包含主代码与测试目录。核心由10个Java源文件实现订餐、订单与打单业务逻辑;4个XML配置文件负责数据库连接、日志记录和系统参数;3个SQL脚本用于建表及初始化数据;3个JFreeChart图形文件以图表方式展示统计结果;另附Markdown说明文档、Git忽略文件、属性文件和开源协议,便于阅读、版本控制与合法使用。目前已有245人学习下载。通过该项目可完整看到Java与XML、SQL、JFreeChart的整合方式,理解食堂订餐场景下的数据流与控制流,并获得一个轻量级可二次开发的项目原型,适合作为课程设计或企业信息化入门参考。
1. 食堂订餐打单系统在 Java 里解决什么问题:一个订单从下单到出票的完整闭环
做过食堂订餐与打单系统的人都有同感:订餐本身只是一张订单表的增删改查,真正花掉大半开发时间的是「打单」这两个字——把订单内容准确无误地送到 58mm 热敏打印机上,还要按档口分单、按队列异步出票、在高峰期扛住并发不丢单。这套源码在国内高校里常以 Java 课程设计案例源码的形式流传,技术栈一般落在 Spring Boot + MySQL + ESC/POS 指令直发,前端用 JSP 或 Thymeleaf 都能跑。它适合三类人:需要交 Java 课程设计或毕业设计的学生,想给单位食堂做一套内部订餐系统的开发者,以及想搞懂订单状态机和小票打印机如何协作的入门后端。难点不在业务功能多,而在设备对接的细节足够折磨人。
2. 先把系统架子搭对:食堂订餐打单的模块边界与数据模型
2.1 模块划分:为什么这个体量用单应用加作业队列就够了
食堂订餐系统的真实负载,和互联网高并发是两回事。一个中等规模食堂,中午高峰大约 300 到 600 人次,摊到一分钟也就 10 到 20 单。这个 QPS 用一台普通服务器跑单体应用绰绰有余,硬上微服务只会把问题复杂化。常见的做法是把工程拆成四个包:controller 负责接收订餐请求,service 处理订单状态流转,mapper 操作 MySQL,printer 单独封装小票打印逻辑。printer 包是这套源码里最值得看的部分,它不依赖任何厂商 SDK,只通过 Socket 或串口往打印机写原始字节流。
模块边界要特别注意一处:打单不能直接挂在下单的 HTTP 请求里同步执行。食堂场景里打印机是共享设备,多个档口同时打印,如果每个下单请求都阻塞等打印机响应,高峰期请求线程会全部卡在 I/O 上。所以 printer 包内部要维护一个独立的打印队列和线程池,service 层下单成功后只负责把打印任务丢进队列,真正的写字节操作在后台线程完成。
2.2 数据表设计:订单、明细、档口与状态机的取舍
食堂订餐的数据模型有个容易踩坑的设计点:档口维度放在明细表而不是订单表。一个用户可能同时点了 1 号窗口的盖浇饭和 2 号窗口的面条,订单是一张,后厨出票却要拆成两张。如果订单表上挂了单个 window_id,分单打印逻辑就写不出来了。下面是核心表的建表语句,字段做了精简,课程设计级别够用:
CREATE TABLE `window` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL COMMENT '档口名称,如 1号盖浇饭', `printer_ip` VARCHAR(32) DEFAULT NULL COMMENT '档口打印机IP,空表示共用前台打印机', `print_port` INT DEFAULT 9100 COMMENT '打印机端口,网口机默认9100' ); CREATE TABLE `dish` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `window_id` BIGINT NOT NULL, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架' ); CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已出票 3已完成 4已取消', `print_count` INT NOT NULL DEFAULT 0 COMMENT '累计出票次数,补打会+1', `created_at` DATETIME NOT NULL ); CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `window_id` BIGINT NOT NULL COMMENT '档口挂在明细上,用于按档口拆单', `dish_id` BIGINT NOT NULL, `dish_name` VARCHAR(64) NOT NULL COMMENT '冗余菜品名,防止改价后历史订单对不上', `price` DECIMAL(10,2) NOT NULL, `quantity` INT NOT NULL );状态机这里我建议保留「已出票」这个中间态。订餐系统里订单状态和打印动作是异步关联的:下单成功写 status=1,后台打印线程写完字节流并确认无异常后,把 status 改成 2。这样出票失败时,运维能一眼看出哪些订单卡在「已支付未出票」,直接触发补打。把订单和打印状态糅在一起反而省掉了这张「打印流水表」的查询逻辑,是个很划算的取舍。
2.3 订餐主流程时序:从点餐到后厨出票的两次写库
整个订餐主流程实际只有两次关键写库。第一次是下单事务内写 orders 和 order_item,同时扣减 dish.stock;第二次是打印线程完成后回写 orders 的 status 和 print_count。两次写入之间靠订单号关联,不引入消息中间件,数据库本身就充当了队列的持久化角色。
下单接口要做的是:校验菜品是否在售、扣库存、生成订单号、写订单和明细、把打印任务丢进内存队列。打印线程要做的是:从队列拿到订单号、查明细、按档口分组、拼小票字节流、写 Socket、回写状态。打同一单的两张档口小票时,要注意第二张票打完后才允许把状态置为已出票,否则第一张成功了第二张失败,会出现订单状态和实际出票不一致的情况。我一般会在打印任务里维护一个计数,全部档口小票都写完再回写状态。
3. 打单子系统怎么做:Java 直连热敏打印机的 ESC/POS 指令封装
3.1 三条打单技术路线怎么选:指令直发、开源库、厂商 SDK
接触过真实食堂项目的人都知道,打单这层用错技术路线会非常被动。市面上对接小票打印机有三条常见路线:走 Socket 或串口直接发送 ESC/POS 原始指令、用开源库封装、调用打印机厂商提供的 SDK。
| 路线 | 外部依赖 | 可控性 | 跨平台 | 适用场景 |
|---|---|---|---|---|
| ESC/POS 指令直发 | 无 | 高,每个字节都可查 | Java 原生跨平台 | 课程设计、自建系统、排错方便 |
| 开源库封装 | 第三方 jar | 中,受库版本束缚 | 较好 | 批量对接多种机型 |
| 厂商 SDK | 厂商驱动和 DLL | 低,黑匣子 | 差,多数只支持 Windows | 固定品牌、Windows 部署 |
我给课程设计和中小型自建系统的建议是选指令直发。原因很直接:开源库和 SDK 一旦出问题,你只能对着报错信息干瞪眼,而 ESC/POS 是一份公开的字节指令集,任何一步出错都能用十六进制工具定位。食堂场景需要的指令不超过十条,封装成本很低,自己写反而最省心。
3.2 ESC/POS 指令封装:初始化、对齐、字体、切纸的最小指令集
食堂小票用到的最小指令集,实际只有六条。所有操作都是往输出流里写一段十六进制字节,Java 里用 byte 数组表示。下面这张表是核心,建议直接存在常量类里:
| 功能 | 指令字节 | 参数说明 |
|---|---|---|
| 初始化打印机 | 1B 40 | 清空缓冲区,复位 |
| 设置对齐方式 | 1B 61 n | n=0 左对齐,1 居中,2 右对齐 |
| 设置字体倍宽倍高 | 1D 21 n | 高位表示倍宽,低位表示倍高 |
| 打印并换行 | 0A | 每次写一行后追加 |
| 走纸 n 行 | 1B 64 n | 出一段空白,便于撕票 |
| 切纸 | 1D 56 01 | 部分切纸,半切不切断 |
封装成一个工具类,输出流从构造器传入,这样后续既可以用 Socket 输出流连真机,也可以用文件输出流做模拟测试。关键代码如下:
public class EscPosPrinter { private static final byte[] INIT = new byte[]{0x1B, 0x40}; private static final byte[] ALIGN_LEFT = new byte[]{0x1B, 0x61, 0x00}; private static final byte[] ALIGN_CENTER = new byte[]{0x1B, 0x61, 0x01}; private static final byte[] FONT_NORMAL = new byte[]{0x1D, 0x21, 0x00}; private static final byte[] FONT_DOUBLE = new byte[]{0x1D, 0x21, 0x11}; // 倍宽倍高 private static final byte[] CUT_PAPER = new byte[]{0x1D, 0x56, 0x01}; private static final byte[] FEED_3_LINES = new byte[]{0x1B, 0x64, 0x03}; private static final byte LF = 0x0A; private final OutputStream out; public EscPosPrinter(OutputStream out) { this.out = out; } public void printOrderTicket(String orderNo, String windowName, List<OrderItem> items, String totalAmount) throws IOException { out.write(INIT); // 标题倍宽倍高居中 out.write(ALIGN_CENTER); out.write(FONT_DOUBLE); writeGbkLine(windowName + " 取餐单"); out.write(FONT_NORMAL); writeGbkLine("订单号:" + orderNo); writeGbkLine("--------------------------------"); // 明细左对齐 out.write(ALIGN_LEFT); for (OrderItem item : items) { writeGbkLine(item.getDishName() + " x" + item.getQuantity() + " ¥" + item.getPrice()); } writeGbkLine("--------------------------------"); writeGbkLine("合计:¥" + totalAmount); writeGbkLine(""); out.write(FEED_3_LINES); out.write(CUT_PAPER); out.flush(); } private void writeGbkLine(String text) throws IOException { // 热敏打印机大多按 GBK 解码,不能用 UTF-8 out.write(text.getBytes("GBK")); out.write(LF); } }这段代码的逻辑说明分三点。构造函数收 OutputStream 而不是直接收 Socket,是为了把设备连接和指令拼装解耦,测试时传 FileOutputStream 就能在本地跑通全流程。writeGbkLine 方法里强制用 GBK 编码,这是小票不乱码的关键,很多新手在这里用默认编码导致中文全是问号,后面避坑章节会展开。每条文本写完立即追加 LF,保证打印机在断电或异常时不丢行。
3.3 小票排版怎么算:字节宽度、行数与三联单的差异
热敏打印机的排版不是按字符数算的,是按字节宽度算。58mm 打印机的打印区域通常 384 点宽,采用 12×24 点阵字体时,一行最多容纳 32 个英文字节(16 个汉字);80mm 打印机打印区域 576 点,一行最多 48 个英文字节(24 个汉字)。GBK 编码下汉字占 2 字节,所以计算行宽必须用 text.getBytes("GBK").length,String.length() 会少算一半。
居中对齐也不能简单靠打印机的 ALIGN_CENTER 指令一发了事。有些廉价热敏机对中英文混排的居中处理有偏差,更稳妥的方式是自己算左侧补空格:先算出文本字节数,用行宽减掉后除以 2,得到左侧空格数。这个算法写起来很简单,却能解决大量「看着没居中」的玄学问题。
食堂打单还有一个特殊场景:一单多档口的拆单排版。订单里有 1 号窗口的盖浇饭和 2 号窗口的面条,需要分别拼两张小票,每张只包含对应档口的明细,并突出显示档口名和订单号。这时候公共部分(订单号、总金额)只在最后一张票打印,避免取餐时用户被多张票搞混。「已出票」状态也必须等最后一张档口票打印完成再回写。
4. 下单与打单的落地代码:Spring Boot 接口加异步打印队列
4.1 下单接口:事务边界与幂等键怎么落
下单接口的事务边界和幂等设计,是整个订餐系统里 Java 后端功底最集中的地方。食堂场景最常见的业务事故是用户连续点了两次支付,系统生成了两张订单,后厨出了两张票,用户取了两份饭。这属于典型的重复提交问题,解决手段是幂等键加唯一索引双保险。
幂等键的策略我用「用户ID + 当天日期」。食堂订餐的业务规则是同一用户每天只允许下一单待取餐订单,这个规则天然适合做幂等。下单接口的代码结构如下:
@PostMapping("/api/order") @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(@RequestBody CreateOrderRequest req) { // 1. 幂等校验:同一用户同一天只能有一个待支付或待取餐订单 String idempotentKey = req.getUserId() + ":" + LocalDate.now(); Order exist = orderMapper.selectByIdempotentKey(idempotentKey); if (exist != null && (exist.getStatus() == 0 || exist.getStatus() == 1)) { throw new BizException("今日已有进行中的订单,请勿重复下单"); } // 2. 构造订单号:时间戳 + 用户ID后四位 + 随机数 String orderNo = generateOrderNo(req.getUserId()); BigDecimal total = BigDecimal.ZERO; // 3. 扣库存,核心用带条件的 UPDATE 保证原子性 for (OrderItemRequest item : req.getItems()) { int updated = dishMapper.reduceStock(item.getDishId(), item.getQuantity()); if (updated == 0) { throw new BizException("菜品已售罄或库存不足,请刷新后重试"); } // 查最新菜品价格并累加 Dish dish = dishMapper.selectById(item.getDishId()); total = total.add(dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 4. 写订单主表和明细表 Order order = buildOrder(orderNo, req.getUserId(), total); orderMapper.insert(order); for (OrderItemRequest item : req.getItems()) { orderItemMapper.insert(buildOrderItem(order.getId(), item)); } // 5. 订单入库成功后,打单任务交给异步队列 printTaskQueue.execute(new PrintTask(order.getId())); return OrderVO.from(order); }这段代码的逻辑重点在第 3 步。reduceStock 对应的 SQL 是UPDATE dish SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0,返回受影响行数,为 0 说明库存已扣光。这是标准的乐观锁扣库存写法,比先 SELECT 再 UPDATE 更安全,也比在 Java 方法上加 synchronized 更符合多实例部署的场景——毕竟你没法保证未来不会横向扩容。
事务边界上要注意:打印任务入队必须在事务提交之后。上面代码里 execute 方法位于事务方法内部,如果队列是内存阻塞队列,任务被消费时事务可能还没提交,打印线程查不到订单明细。常见的规避做法是在 service 层用 TransactionSynchronizationManager 注册 afterCommit 回调,或者在控制器层把入队动作挪到 service 调用之后。课程设计图省事的话,用后一种即可。
4.2 异步打单:为什么不能在 HTTP 线程里直接阻塞打印
直接在控制器线程里同步调打印机,高峰期会出大问题。食堂午高峰 10 到 20 单/分钟,一台打印机打一张小票要 1 到 2 秒,如果同步执行,光打印就把请求线程占满了,更别说打印机偶尔卡纸、缺纸时的超时等待。所以打单必须异步化,用有界阻塞队列加线程池是最直接的做法。
@Component public class PrintTaskQueue { private static final Logger log = LoggerFactory.getLogger(PrintTaskQueue.class); private final ThreadPoolExecutor executor; public PrintTaskQueue(@Value("${printer.pool.core-size:2}") int coreSize, @Value("${printer.pool.max-size:4}") int maxSize, @Value("${printer.pool.queue-capacity:500}") int queueCapacity) { this.executor = new ThreadPoolExecutor( coreSize, maxSize, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueCapacity), new ThreadFactoryBuilder().setNamePrefix("print-worker-").build(), new CallerRunsPolicy()); } public void execute(PrintTask task) { executor.execute(task); } public static class PrintTask implements Runnable { private final Long orderId; public PrintTask(Long orderId) { this.orderId = orderId; } @Override public void run() { // 查订单和明细,按档口分组拼票,逐张发送 // 全部成功后:orderMapper.updateStatus(orderId, 2) } } }拒绝策略选 CallerRunsPolicy,而不是 AbortPolicy 或 DiscardPolicy,原因是打单任务丢不起。队列满了说明打印已经严重积压,此时让提交任务的 HTTP 线程自己执行打印,虽然会让接口变慢,但至少不会丢单。在食堂这种低并发场景,队列容量 500 基本不会触发拒绝,这个策略只是兜底。
4.3 打印机与线程池的初始化参数:连接超时、队列容量、拒绝策略
打印子系统的参数配置直接决定了系统在真实环境下的表现。我给一组经过实践验证的参数建议,放在 application.yml 里:
printer: pool: core-size: 2 # 核心线程数,和打印机台数保持一致 max-size: 4 # 峰值线程,不超过打印机数量的2倍 queue-capacity: 500 # 排队打单上限,超出触发CallerRunsPolicy connection: connect-timeout-ms: 3000 # 连接超时3秒,超过直接判定打印机离线 read-timeout-ms: 2000 # 读响应超时,部分打印机不支持回传 max-idle-ms: 60000 # 空闲1分钟后关闭连接,省打印机资源| 参数 | 建议值 | 设计理由 |
|---|---|---|
| core-size | 与打印机台数相同 | 每台打印机一个专职线程,避免频繁创建 |
| max-size | 打印机台数 × 2 | 应对补打高峰,超过这个值说明打印机故障了 |
| queue-capacity | 500 | 按午高峰峰值估算,单日订单量的 80% |
| connect-timeout-ms | 3000 | 网口打印机握手很慢,太短误判,太长阻塞线程 |
| max-idle-ms | 60000 | 长时间占用连接会让打印机过热,空闲要释放 |
连接管理的策略是:每台打印机维护一个 Socket 连接池,空闲 60 秒自动关闭。打印前从池子里借用连接,写完后归还。食堂场景不建议为每单新建 Socket,打印机 TCP 栈很浅,频繁建连会让打印机重启,这是不少现场项目把打印机「打挂」的常见原因。
5. 避坑记录:食堂订餐打单最常见的五个翻车现场
5.1 小票乱码:出餐单变成问号,问题不在 UTF-8 而在 GBK
现象:小票上的中文全部变成问号或空白,英文和数字正常。 原因:绝大多数热敏打印机出厂固件按 GBK/GB2312 解码,而 Java 默认字符集在 JDK18 前是 UTF-8,text.getBytes()不带参数时输出的就是 UTF-8 字节流,打印机不认。 解决:所有写入打印机的文本强制getBytes("GBK")。注意getBytes("GBK")方法声明会抛 UnsupportedEncodingException,用 try-catch 包住或直接在方法签名上抛出。初始化打印机后最好先写一条中文测试文本验证编码生效,不要等订单打出来了才发现。
5.2 小票打到一半中断:换行符和缓冲区把数据吃掉了
现象:小票内容只出了一半,后面几行明细丢失,或者最后一张票的结尾被截断。 原因:两个因素叠加。一是行宽超限,一行文本超过 32 字节(58mm 机)后打印机自动截断,明细名称太长时最先受害;二是输出流没 flush,数据还留在 JVM 缓冲区里打印机就已经切纸了。 解决:排版时用text.getBytes("GBK").length校验每行字节数,超过上限就截断加省略号。flush 位置要在 CUT_PAPER 之前,而且要调用打印机输出流自身的方法,不是 System.out。切纸指令如果丢了,票打出来不切断,后厨撕票时会连带下一张一起撕坏。
5.3 网络打印机偶尔连不上:固定 IP、超时和断线重连
现象:打印机平时正常,午餐高峰突然连不上,报 Socket connect timeout,过一会儿自己恢复。 原因:打印机用的是廉价网卡,TCP 并发连接数极小。高峰期多个线程同时建连,打印机网卡资源被耗尽,直接拒绝新连接。另一种是 DHCP 租约过期,打印机 IP 变了,配置里的旧 IP 自然连不上。 解决:给打印机在路由器里做 DHCP 地址绑定,固定 IP。连接池只允许每台打印机同时一个线程写入,core-size 不要超过打印机台数。连接失败时不要把异常抛给用户,而是把打印任务放进失败重试队列,5 秒后重试,重试 3 次仍然失败再标记订单为「打印异常」,允许前台手动补打。断线重连的逻辑要写在任务执行里,不要写在应用启动时,打印机随时可能重启。
5.4 重复下单刷出两张票:幂等键没覆盖到支付回调
现象:用户在收银台点了支付,支付成功回调也触发了,最后出了两张相同的取餐票。 原因:下单接口只在创建订单时做了幂等校验,但很多食堂系统接入了扫码支付,支付回调里又会调一次「确认订单并打单」的逻辑,这个入口没做幂等,导致同一订单被二次入队。 解决:幂等校验覆盖到所有能触发打单的入口。更稳的做法是订单表加一个printed_flag字段,打单线程执行前先UPDATE orders SET printed_flag = 1 WHERE id = ? AND printed_flag = 0,受影响行数为 0 说明已经打过,直接跳过。这个方案不依赖幂等键在业务层的正确性,数据库层面兜底。
5.5 高峰期并发点餐,库存超卖:版本号比 synchronized 靠谱
现象:菜品显示还剩最后一份,两个人同时下单都成功了,后厨做了两份。 原因:扣库存用的是SELECT stock FROM dish WHERE id = ?然后 Java 里判断 stock > 0 再UPDATE,两个请求查到的都是 1,都通过了判断,都执行了更新。 解决:把扣库存改成单条原子 SQL,UPDATE dish SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0,用受影响行数判断是否成功。这个方案处理的是数据库层面的并发,和 Java 方法上加不加 synchronized 无关。实际上在单实例部署时 synchronized 也能挡,但一旦将来拆成多实例部署,synchronized 就失效了,而原子 SQL 在任何部署形态下都成立。
6. 没有真打印机怎么验证:文件模拟打单与出票时间线追踪
6.1 把输出流换成文件流,端到端跑通整个订餐打单链路
开发时手边没有热敏打印机,是这门课最容易卡住的环节。解决办法是充分利用 EscPosPrinter 构造器注入 OutputStream 这个设计,把真机连接替换成文件输出流:
// 模拟测试:真机环境传 Socket 输出流,测试环境传文件输出流 FileOutputStream fos = new FileOutputStream("ticket_test.bin"); EscPosPrinter printer = new EscPosPrinter(fos); printer.printOrderTicket("20250612001", "1号盖浇饭", buildMockItems(), "24.50"); fos.close();跑完这条测试后,用十六进制查看工具打开 ticket_test.bin,逐个核对字节:文件头部应当是 1B 40(初始化),中间文本区域应当能看到完整的 GBK 编码中文,尾部应当有 1B 64 03(走纸)和 1D 56 01(切纸)。这个验证方法能发现八成以上的指令拼装错误,比直接在真机上试错高效得多。我习惯在写设备连接代码之前先跑一遍文件模拟,把指令流调对了再接真机。
6.2 用订单状态日志还原出票时间线,快速定位丢单环节
打单是异步链路,订单状态分布在多个表字段和内存队列里,出了问题很难直接看。我的做法是在关键节点打结构化日志,日志里带统一的订单号和时间戳,事后用一条命令还原整张订单的生命周期。下单时记录「创建订单」,入队时记录「打印任务入队」,线程执行时记录「开始写打印机」,回写状态时记录「出票完成」。四个节点的时间差,能直接定位瓶颈在数据库、队列还是设备。
排查时先看订单表里 status 字段停在哪个值。停在 1 说明打印任务没执行或执行失败,去日志里搜订单号看最后一条记录;停在 2 说明票已出但用户没取餐,是线下流程问题。这套验证方法用顺手之后,食堂打单系统就不再是黑匣子了。做这类课程设计,我个人的教训是:先把打单这层用文件模拟跑通,再接真机联调,顺序不要反,否则你会被设备问题和代码问题混在一起的状态折磨到怀疑人生。希望帮到你。
本文还有配套的精品资源,点击获取