ARTICLE DETAIL

资讯详情

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

Java食堂订餐打单系统:订单状态机与ESC/POS小票打印实践

Java食堂订餐打单系统:订单状态机与ESC/POS小票打印实践 简介一套基于Java实现的食堂订餐与打单系统设计源码面向校园食堂日常运营场景可模拟从订餐到打单的完整流程提升运营效率适合Java学习者、毕业设计者及餐饮系统开发人员参考。资源包共25个文件整体约107KB其中10个Java源文件构成系统核心逻辑4个XML配置文件负责系统参数与组件配置3个SQL脚本用于数据库建表与初始化3个JFreeChart图形文件实现数据统计可视化另含Markdown文档、属性文件、Git忽略文件及开源协议文件覆盖编码、配置、文档、版本控制等完整环节。已有245人学习可直接用于理解Java分层开发、食堂订餐业务的数据建模以及JFreeChart图表的集成方式项目结构清晰数据库脚本开箱即用还附带说明文档与版本管理配置既适合作为课程设计或毕业设计的参考源码也适合快速搭建一个轻量级订餐与打单后台。1. 食堂订餐与打单系统到底是什么三天就能搭出最小闭环饭点食堂的窗口前永远排着长队窗口阿姨一边算钱一边手写小票高峰期错单、漏单、算错价格是家常便饭。基于Java实现的食堂订餐与打单系统核心就是把“选餐 - 下单 - 结算 - 打单 - 取餐”这条链路变成代码里的状态流转。它不需要高并发架构难的是把业务闭环串起来尤其是“打单”这一步。适合两类人做Java课程设计或毕业设计、想找一个能跑起来的完整案例源码的人以及给企业或学校食堂做小规模信息化的开发者。常见技术选型是 Spring Boot MyBatis MySQL前端用 Vue 或 Thymeleaf打单走 ESC/POS 小票打印。反直觉的结论是订单表设计得再漂亮状态机没有设计好三天之后就会翻车。2. 需求拆解与整体架构先把订单和打单流程理清楚这个系统听起来简单真正动手写代码之前建议先花一天时间把“角色”和“订单状态”画出来。很多半途而废的项目都是因为把打单理解成“点击打印按钮”忽略了订单在哪个节点状态下才能打印、打印之后如何标记已出餐、忘记打单如何补打。这些只要状态机设计漏一项后期全是补丁。2.1 三个角色和六种订单状态从用例到状态机食堂订餐系统最常见的角色是三类就餐用户学生或员工、食堂操作员、系统管理员。用户负责选餐、下单、支付或记账操作员负责查看待打单订单、点击打单、标记出餐管理员维护菜品、价格、食堂公告和用户数据。实际部署中有些食堂把打单动作放在用户支付后自动触发有些则放在取餐窗口由操作员手动触发所以订单状态不能简单写一个“已支付”。我一般会把订单状态设计成六种待支付CREATED、已支付PAID、待打单READY_TO_PRINT、已打单PRINTED、已完成COMPLETED、已取消CANCELLED。这里“待打单”和“已打单”要拆开是因为很多食堂的场景是用户先在手机上下单支付再到窗口报号打单如果支付后直接打单那么“待打单”状态可以跳过但有了这个状态补打和重打才能做成一个独立的动作。状态流转的边界条件比状态本身更重要。例如只有 PAID 状态才能进入 READY_TO_PRINT只有 READY_TO_PRINT 或 PAID 才允许打印PRINTED 之后不允许再修改订单金额CANCELLED 只能从 CREATED 或 PAID 流转不能从 PRINTED 直接取消因为小票已经打印出来要做退款并记录原因。这些规则用 Java 枚举加一个状态转换矩阵来约束比散落在 Service 里的 if 判断要可靠得多。下面是订单状态机的 Java 枚举定义可以作为源码骨架直接使用public enum OrderStatus { CREATED(待支付), PAID(已支付), READY_TO_PRINT(待打单), PRINTED(已打单), COMPLETED(已完成), CANCELLED(已取消); private final String desc; OrderStatus(String desc) { this.desc desc; } public String desc() { return desc; } /** * 校验当前状态能否流转到目标状态。 * 返回 true 表示允许否则抛出异常由统一异常处理器转成 400/409 响应。 */ public boolean canTransitionTo(OrderStatus target) { return switch (this) { case CREATED - target PAID || target CANCELLED; case PAID - target READY_TO_PRINT || target PRINTED || target CANCELLED; case READY_TO_PRINT - target PRINTED || target CANCELLED; case PRINTED - target COMPLETED; case COMPLETED, CANCELLED - false; }; } }这段代码里有一个容易被忽略的点switch 里的 CREATED 状态允许直接到 CANCELLED但 PAID 也允许到 CANCELLED而 READY_TO_PRINT 只能到 PRINTED 或 CANCELLED。也就是说只要订单还没打印都有取消的可能一旦打了小票就只允许完成不允许取消要取消必须走线下退款并单独销账。这是和普通电商订单不一样的地方因为小票是食堂窗口出餐的依据打印后撤销会造成窗口对不上账。如果你的业务里支付后立即自动打单那 PAID 到 PRINTED 这一流转可以合并但建议保留 READY_TO_PRINT 作为中间态方便以后接入“取餐叫号”“补打小票”等功能。2.2 技术选型为什么是 Spring Boot MyBatis MySQL这个系统不需要微服务也不需要消息队列单体应用完全够用。选择 Spring Boot 是为了省去大量 XML 配置内嵌 Tomcat后端打包成一个可执行 jar 就能跑部署成本低。MyBatis 在这里比 JPA 更合适因为食堂订单查询往往要写比较复杂的统计 SQL比如“按菜品汇总销量、按日统计食堂营业额”MyBatis 手写 SQL 更直观也不容易因为懒加载产生 N1 问题。MySQL 则是最稳妥的开源关系型数据库方便后续接报表。前端的选择看团队如果只给食堂窗口两三个打单终端用用 Thymeleaf 服务端渲染就够页面少不需要前后端分离如果学生端也要在手机上选餐那前端需要 Vue 或 UniApp 做 H5 或小程序。不过注意标题里的“打单”通常是指厨房或收银台的热敏小票打印Java 后端不会直接操作浏览器 DOM 打印而是通过 javax.print 将 ESC/POS 指令发送给打印机后端和打印机通常在同一台机器或同一个局域网。打单的通信方式有两种常见做法一是 Java 程序通过 javax.print API 直接调用本机已安装的打印机驱动简单但只能在装有驱动的电脑上跑二是把打印机设置为 TCP/IP 网络打印机Java 通过 Socket 发送 ESC/POS 字节流。对食堂这种环境我一般建议用 TCP/IP 网络打印这样后端服务可以部署在任意一台电脑上打单终端用网线或 Wi-Fi 连打印机。这个选择会直接影响后面的打印代码怎么写建议在架构评审时就定下来。2.3 建表设计与源码骨架一份可以落地的 MySQL DDL设计完状态机下一步就是建表。核心表一般有五张用户表system_user、菜品表dish、订单主表orders、订单明细表order_item、打印日志表print_log。这里有一个很常见的坑订单明细里不要只存菜品 ID 和数量一定要冗余一份菜品名称和下单时的单价因为菜品价格会变历史订单必须保留下单时的快照。下面是精简后的建表 SQL重点看订单表和打印日志表CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, remaining INT NOT NULL DEFAULT 0 COMMENT 当日剩余份数, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_dish_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT CREATED, cancel_reason VARCHAR(255) DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_orders_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(64) NOT NULL COMMENT 下单时的菜品名快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时的单价快照, quantity INT NOT NULL DEFAULT 1, KEY idx_item_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE print_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, printer_name VARCHAR(128) NOT NULL, content_md5 CHAR(32) NOT NULL COMMENT 小票内容MD5防止重复打印, status VARCHAR(20) NOT NULL DEFAULT SUCCESS, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_print_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的字段设计有几个关键参数说明。dish 表用 version 字段做乐观锁防止并发下单时把“剩余份数”扣成负数orders 表的 status 字段直接存枚举名称而不是数字因为字符串可读性强、排查问题时一眼能看出状态order_item 里的 dish_name 和 price 是快照字段这是订单系统的基本修养。打印日志表里的 content_md5 用来做幂等同一订单内容相同的小票不能反复打印这在后面打单逻辑里会用到。源码目录结构我一般按横向分包controller / service / mapper / entity / common不做前后端分离时静态页面放在 resources/templates。如果你拿到一个现成源码包第一件事不是跑起来而是先看 orders 表和 print_log 表有没有再看状态机是否完整。很多号称完成的源码缺的就是这两块。3. 把订餐与打单跑通从下单接口到小票出纸的落地代码3.1 下单接口Controller-Service-Mapper 三层怎么串订单生成是系统的核心事务接口设计上要接收用户 ID、菜品 ID 列表和数量。这里要特别注意前端传过来的单价一律不信后端必须从数据库重新取菜品当前价格去计算总价。否则用户抓包改一下价格食堂就得亏损。先看 Mapper 接口的定义Mapper public interface DishMapper { Dish findById(Long id); /** * 乐观锁扣减库存。 * 返回受影响行数0 表示库存不足或版本冲突。 */ int decreaseStock(Param(id) Long id, Param(count) int count, Param(version) int version); }对应的 XML 里那个 update 语句是关键update iddecreaseStock UPDATE dish SET remaining remaining - #{count}, version version 1 WHERE id #{id} AND remaining #{count} AND version #{version} /update这里很容易被忽略的是 where 里同时带了 remaining #{count} 和 version #{version}。如果只写 remaining count两个请求同时读到剩余 5 份各扣 3 份事务一提交就会变成 -1加了 version 条件第二次 update 影响行数为 0我们就能在 Service 层拿到这个结果抛“库存不足”异常并让整个事务回滚。对应的 Service 方法长这样Transactional public Long createOrder(CreateOrderRequest req) { ListOrderItem items new ArrayList(); BigDecimal total BigDecimal.ZERO; for (OrderItemRequest itemReq : req.getItems()) { Dish dish dishMapper.findById(itemReq.getDishId()); if (dish null) { throw new BizException(菜品不存在: itemReq.getDishId()); } int rows dishMapper.decreaseStock(dish.getId(), itemReq.getQuantity(), dish.getVersion()); if (rows 0) { throw new BizException(菜品库存不足或已更新: dish.getName()); } BigDecimal subtotal dish.getPrice().multiply(BigDecimal.valueOf(itemReq.getQuantity())); OrderItem item OrderItem.builder() .dishId(dish.getId()) .dishName(dish.getName()) .price(dish.getPrice()) .quantity(itemReq.getQuantity()) .build(); items.add(item); total total.add(subtotal); } Order order Order.builder() .orderNo(OrderNoGenerator.next()) .userId(req.getUserId()) .totalAmount(total) .status(OrderStatus.CREATED) .build(); orderMapper.insert(order); orderItemMapper.batchInsert(order.getId(), items); return order.getId(); }这段代码的逻辑说明注解 Transactional 保证“扣库存 写订单 写明细”要么全成功要么全回滚为了减少数据库查询这里在循环里先 findById 再 decreaseStock看似两次查询但实际业务里菜品数量有限性能可以接受。你会注意到我在扣库存时没有像有些 demo 那样“先 select for update”因为用的是乐观锁并发冲突时直接走业务异常重试即可不用锁表。参数上需要注意两个地方一是金额计算必须用 BigDecimal禁止用 double否则会出现 0.1 0.2 不等于 0.3 的尴尬二是 order_no 不要直接用数据库自增 ID建议生成“日期 两位食堂编号 四位随机数”的短单号打印小票时数字越短越好窗口报号也方便。3.2 打单服务生成小票内容并发送到 TCP 打印机下单完成后订单状态从 CREATED 变更为 PAID接下来进入打单环节。这里我先把打印内容生成和发送拆成两个方法方便后续单测。小票内容通常包含订单号、时间、菜品明细、单价、数量、总价、备注。以下代码演示的是通过 Socket 连接网络热敏打印机的核心逻辑public void printOrder(Long orderId) { Order order orderMapper.findById(orderId); if (order.getStatus() ! OrderStatus.PAID order.getStatus() ! OrderStatus.READY_TO_PRINT) { throw new BizException(当前状态不允许打单: order.getStatus()); } String content buildTicketContent(order); String md5 DigestUtils.md5DigestAsHex(content.getBytes(StandardCharsets.UTF_8)); // 幂等检查内容相同的订单不重复打印 if (printLogMapper.existsByOrderIdAndContentMd5(orderId, md5)) { throw new BizException(该订单已经打印过相同内容的小票); } try (Socket socket new Socket(printerConfig.getHost(), printerConfig.getPort())) { OutputStream out socket.getOutputStream(); // 部分热敏打印机需要先初始化 out.write(new byte[]{0x1B, 0x40}); out.write((订单号 order.getOrderNo() \n).getBytes(StandardCharsets.GBK)); // ... 按行拼接明细 out.write(0x0A); out.write(new byte[]{0x1D, 0x56, 0x41}); // 切纸指令 out.flush(); } catch (IOException e) { throw new BizException(打印失败请检查打印机连接: e.getMessage(), e); } OrderStatus nextStatus order.getStatus() OrderStatus.PAID ? OrderStatus.PRINTED : OrderStatus.READY_TO_PRINT; orderMapper.updateStatus(orderId, nextStatus, order.getVersion()); printLogMapper.insert(orderId, printerConfig.getName(), md5, SUCCESS); }这段代码里有几个值得留意的参数。new byte[]{0x1B,0x40} 是 ESC/POS 的初始化命令避免打印机里残留上一个任务的状态真实小票内容最终建议打成一行 32 个中文字符宽度下面第 4 章会专门讲。0x1D,0x56,0x41 是切纸命令不同厂商命令稍有差异比如爱普生和佳博都支持这个但某些仿真模式打印机不支持实际部署时需要查打印机指令手册。最需要注意的是编码小票内容我用 GBK 编码写入 Socket这是因为很多热敏打印机出厂默认字库是 GBK/ASCII直接写 UTF-8 会在小票上打印出乱码或问号。如果打印机支持 UTF-8可以通过初始化指令切换字库不支持的话就用 GBK。这个坑相当常见后面避坑章节还会重点说。3.3 幂等与补打print_log 表怎么用上面代码里已经用 print_log 表做了幂等判断。很多系统第一次做打单功能时只在 orders 表加一个 printed 标志结果网络超时导致实际已经出票但程序认为失败用户重启后再点打印窗口开了两张票。用 print_log 记录“哪张订单、哪个打印机、打了什么内容”的 md5就能识别重复打印。补打场景也要靠 print_log用户小票丢了窗口重新补打这时候订单状态已经是 PRINTED不能走正常打单流程但可以单独调一个 reprepare 方法仍然检查该订单是否已经打印过但允许操作员在业务层确认后绕过幂等检查。也就是说print_log 不是禁止补打而是给你一个依据系统知道上一次打的是哪张票。4. 小票排版与订单状态的细节决定这个系统能不能用很多项目代码能跑通但食堂窗口用一天就放弃了原因往往不是下单功能而是小票排版难看得没法用或者订单状态混乱导致对不上账。这一章要把这两个细节做到位。4.1 小票排版为什么逃不过“列对齐”这门玄学热敏纸宽度一般是 58mm一行大约能打印 32 个英文字符或 16 个中文。菜单和价格要对齐靠的不只是空格而是计算中英文混排的显示宽度。Java 里的 String.length() 和实际打印宽度是两回事中文在 GBK 编码下占两个字节数字和字母占一个字节因此用 string 的 charAt 去数误差很大。我自己经常用一个工具方法按“视觉宽度”拼接public static String padRight(String text, int totalWidth) { int currentWidth displayWidth(text); int padCount totalWidth - currentWidth; if (padCount 0) { return text; } return text .repeat(padCount); } public static int displayWidth(String text) { int width 0; for (char c : text.toCharArray()) { width (c 0xFF) ? 2 : 1; } return width; }这里的逻辑说明displayWidth 把 ASCII 字符当作 1 个字符宽中文等非 ASCII 字符当作 2 个字符串宽padRight 根据这个宽度计算需要补多少空格。注意 0xFF 这个边界中文编码在 Java 的 char 范围里是大于 255 的但有些扩展字符比如中文全角标点也会大于 255所以用 c 0xFF 作为判定条件。排版时要注意小票头的“食堂名称”要居中价格列固定在右侧菜品名称列最多截断到 22 个字符宽超过就用省略号。窗口阿姨打单时最怕看到一行字换行换行后第二个字段错位所以宁可截断也不要换行。下面是一个真实小票输出的示例幸福路第一食堂 2024-06-12 11:32 单号: 2024061211329 ------------------------------ 菜品名称 数量 金额 鱼香肉丝 1 12.00 番茄炒蛋 1 8.00 米饭 2 4.00 ------------------------------ 合计 24.00这段示例中分割线用 30 个“-”字号实际宽度正好是小票纸宽度。你可能注意到我特意留了两个空格缩进这是为了让分割线在小票打印出来后看起来是满行不会因为边缘打印头磨损而缺边。不同打印机进纸宽度有细微差异我一般用 30 个字符而不是 32 个宁可少打两个字符保证最右边的金额不会出纸边。4.2 支付状态和退款状态别把“支付成功”当成“已确定”订单状态机在落地时会遇到两个现实分支如果接入了微信或支付宝支付支付回调才能把订单从 CREATED 置为 PAID如果食堂是刷饭卡或记账模式那 PAID 状态由后台管理员手动确认。无论哪种都要考虑“支付成功但订单已关闭”的情况比如用户下单后不支付过 15 分钟系统自动关闭此时回调迟到就会把已关闭订单改成已支付。我一般会在 orders 表加一个 payment_time 字段状态机修改为只有 CREATED 能到 PAID如果订单已经 CANCELLED回调必须忽略。同时CANCELLED 状态下如果已经打了小票退款金额不能直接从原订单反算需要在 cancel_reason 里记录操作员账号和退款金额。这些细节在源码里体现为一行行 if 判断但如果没有提前定义好很容易出现支付回调把状态覆盖掉的 bug。4.3 订单查询的性能点按状态和时间降序建索引订单查询接口是窗口最常用的页面每天几百单数据量不大但“按状态 按时间倒序”这个组合如果没建索引数据量积累到几万条后查询会明显变慢。orders 表里那句 KEY idx_orders_status_created (status, created_at) 就是为这个查询服务的WHERE statusPAID ORDER BY created_at DESC。这种复合索引能直接命中不用回表排序。如果食堂要按菜品维度统计销量order_item 表的索引建议再加一个 (dish_id, order_id) 复合索引因为统计 SQL 经常是 GROUP BY dish_id JOIN dish。不过不要在 order_item 上建太多索引否则插入明细时多一次索引维护影响下单事务。这是一个典型的“读多写少”场景索引收益远大于代价放心建。5. 从坑里爬出来订餐打单系统 5 条踩坑排错记录讲完设计和代码下面这些坑是我实际做完几个食堂项目后留下的血泪经验。每条都按“现象 - 原因 - 解决”写你可以直接拿去做排查手册。5.1 小票乱码或中文变成问号现象小票上菜品中文全部变成 ? 或者乱码英文数字正常。原因绝大多数热敏打印机默认字库是 GBK/ASCII我们用 UTF-8 发送中文打印机识别不了。另外新装的热敏打印机驱动默认编码可能是 UTF-8但 ESC/POS 原生命令走 Socket 时不经过驱动还是要看打印机固件。解决把发送给打印机的字节流统一改成 GBK 编码即代码里用 StandardCharsets.GBK。如果打印机支持 Unicode可以在初始化指令后发送切换字库命令但不同厂家命令不同稳妥做法是先查打印机手册没有明确说明就默认 GBK。还有一点连接 TCP 打印机前先用厂商提供的工具确认打印机本身打印中文正常排除打印机故障。如果必须用驱动模式可以先把系统区域语言调整为“简体中文GBK”再重启服务很多乱码问题就此消失。5.2 订单明明成功但小票没出过一会又补出一张现象食堂高峰期点击下单用户看到支付成功但窗口没出小票重启服务后却发现这张订单打了两次。原因网络传输是异步的Socket 发送数据后程序抛超时异常实际上打印机已经收到并打印了。此时代码里如果只把“打印失败”理解成订单状态不变用户再点一次就产生了同一张订单两次打印。解决把 print_log 表做严每次打单前先查 order_id content_md5 是否已存在存在就拒绝打印动作放在事务外也就是打印成功后先写 print_log 再更新订单状态。如果 Socket 超时但无法确认打印机是否出票可以加一个“确认识别”步骤人工确认后再重打。很多团队在这里会犯一个错把打印逻辑放在 Spring 事务里事务提交后才去打印但打印超时会导致事务长时间不释放数据库连接池瞬间被占满。所以我建议把“发送打印请求”放在事务外或者用异步线程去执行订单状态更新和打印状态分开写这样即使打印机故障订单也还在 PAID可以手动补打。5.3 多个窗口同时打单重复扣库存现象两个用户同时下单同一道菜剩余 5 份两个请求都成功最后剩余变成 -1 或 3 份但实际超卖。原因下单代码没有加库存校验或者校验和扣减不是原子操作。典型的错误是 Service 里先 select 再判断然后 update两步之间存在并发窗口。解决使用前面第 3 章的乐观扣减 SQLwhere 条件同时带 remaining #{count} 和 version #{version}update 返回 0 行就抛异常。如果在 MySQL 单库上运行也可以直接用 select ... for update 锁行但乐观锁并发失败率低、不需要长事务我更推荐乐观锁。需要说明的是乐观锁更新失败后要立刻返回给用户“菜品库存不足”而不是静默重试否则用户看到转圈很久最后才弹错误。遇到并发冲突时也不要盲目重试三次因为用户看到的是真实的库存不足更不要用 Thread.sleep 做重试这会阻塞 Tomcat 线程。5.4 金额用 double 导致小票总价差一分钱现象某道菜价格是 0.1 元两份合计在某些小票上是 0.2 元在另一个订单里变成 0.199999999 之类的打印出来非常尴尬。原因Java 里 double 和 float 是二进制浮点数0.1 无法精确表示。如果代码里用 double 做乘法结果会有精度误差。解决所有金额字段和计算一律使用 BigDecimal数据库字段用 DECIMAL(10,2)前端 JSON 序列化时也要避免转成浮点数。如果用了 Lombok 的 Builder注意 BigDecimal 没有默认值要在构造时初始化为 ZERO防止 null 参与运算。后端 JSON 返回 BigDecimal 可能会被序列化成数字并丢失精度建议在 Spring Boot 中给 BigDecimal 配 ToStringSerializer或者把金额字段转成字符串传给前端。这样订单详情页展示的是 24.00 而不是 24。数据库查询用的 SUM 函数返回 Decimal 也要注意MyBatis 映射时不要用 Float 接收。5.5 打印格式在测试环境下正常一到食堂就偏移现象同一套代码在办公室用的打印机上小票很整齐到食堂后金额列向右偏了两格或者左边出纸边。原因办公室打印机是 80mm食堂是 58mm打印宽度不同。还有一些打印机默认设置走了“压缩字体”或“放大倍数”导致字符宽度不是标准的 1 或 2。解决正式部署前用同一型号打印机做一次“校准”测量一行最多能打多少个英文字符然后配置在打印机参数表里。代码里不要硬编码 30 个字符宽度而是把纸张宽度放到配置项比如 print.ticket.width32按实际打印机调整。另外小票模板里避免使用制表符 \t因为不同打印机的制表位宽度不一致务必用空格对齐。部署时还要做一次实测打印一行 32 个“0”用尺量它是否刚好填满纸宽打印一行“一二三四五六七八九十”确认中文宽度是英文的两倍。不同型号的打印机换纸后还要重新校准一次因为纸宽和打印头热敏层可能不同。6. 进阶验证这套系统做得对不对以及下一步怎么扩到这里最小闭环已经能跑通但这套系统离“敢让食堂每天都用”还有一段路。我一般交付前会做三件事。第一是稳定性的压力验证用 JMeter 或一个简单的 Java 线程池脚本模拟 20 个并发用户同时下单同一道菜品观察库存是否超卖、订单明细是否完整、小票是否重单。第二是打印格式验证把小票内容输出成文本文件和打印机的实际效果逐一比对重点检查中文对齐和金额精度。第三是状态机回归测试写一个单测遍历 OrderStatus 的 canTransitionTo 方法把所有合法与非法流转都断言一遍防止后面加功能时破坏了状态约束。扩展方向上这个系统最值得优先做的是消息通知和退款。用户下单支付后可以用企业微信或钉钉机器人把“待打单订单号”推送给食堂窗口避免窗口一直盯着屏幕刷新。退款则要加一张 refund_record 表关联原订单记录退款金额、退款人、退款原因同时把 orders 状态从 PRINTED 改成 REFUNDED这需要给枚举增加一个新状态。这两个功能加完后食堂对账会轻松很多。我的一个小习惯是先写状态机和打印日志表再写页面。状态机和 print_log 是整个系统最容易出鬼的地方它们稳定了后面加什么功能都不会伤筋动骨。如果你拿到一个现成的源码包也建议先看这两个文件如果状态只有四个字段里拼凑的没有枚举约束尽早重构如果打印日志表不存在打单功能一定会在某个中午突然崩掉。这套方向值得做规模小、收益直接但一定要把那几张表设计好。希望帮到你。本文还有配套的精品资源点击获取
返回列表