ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从订餐到出票:Java食堂打单系统与ESC/POS热敏打印机对接实战

从订餐到出票:Java食堂打单系统与ESC/POS热敏打印机对接实战 简介这是一套基于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 操作 MySQLprinter 单独封装小票打印逻辑。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 );状态机这里我建议保留「已出票」这个中间态。订餐系统里订单状态和打印动作是异步关联的下单成功写 status1后台打印线程写完字节流并确认无异常后把 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 nn0 左对齐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, ListOrderItem 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).lengthString.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-capacity500按午高峰峰值估算单日订单量的 80%connect-timeout-ms3000网口打印机握手很慢太短误判太长阻塞线程max-idle-ms60000长时间占用连接会让打印机过热空闲要释放连接管理的策略是每台打印机维护一个 Socket 连接池空闲 60 秒自动关闭。打印前从池子里借用连接写完后归还。食堂场景不建议为每单新建 Socket打印机 TCP 栈很浅频繁建连会让打印机重启这是不少现场项目把打印机「打挂」的常见原因。5. 避坑记录食堂订餐打单最常见的五个翻车现场5.1 小票乱码出餐单变成问号问题不在 UTF-8 而在 GBK现象小票上的中文全部变成问号或空白英文和数字正常。 原因绝大多数热敏打印机出厂固件按 GBK/GB2312 解码而 Java 默认字符集在 JDK18 前是 UTF-8text.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都通过了判断都执行了更新。 解决把扣库存改成单条原子 SQLUPDATE 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 说明票已出但用户没取餐是线下流程问题。这套验证方法用顺手之后食堂打单系统就不再是黑匣子了。做这类课程设计我个人的教训是先把打单这层用文件模拟跑通再接真机联调顺序不要反否则你会被设备问题和代码问题混在一起的状态折磨到怀疑人生。希望帮到你。本文还有配套的精品资源点击获取
返回列表