简介:一套基于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 更直观,也不容易因为懒加载产生 N+1 问题。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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的字段设计有几个关键参数说明。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 id="decreaseStock"> 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) { List<OrderItem> 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 status='PAID' 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 章的乐观扣减 SQL,where 条件同时带 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.width=32,按实际打印机调整。另外,小票模板里避免使用制表符 \t,因为不同打印机的制表位宽度不一致,务必用空格对齐。部署时还要做一次实测:打印一行 32 个“0”,用尺量它是否刚好填满纸宽;打印一行“一二三四五六七八九十”,确认中文宽度是英文的两倍。不同型号的打印机,换纸后还要重新校准一次,因为纸宽和打印头热敏层可能不同。
6. 进阶:验证这套系统做得对不对,以及下一步怎么扩
到这里,最小闭环已经能跑通,但这套系统离“敢让食堂每天都用”还有一段路。我一般交付前会做三件事。第一是稳定性的压力验证:用 JMeter 或一个简单的 Java 线程池脚本,模拟 20 个并发用户同时下单同一道菜品,观察库存是否超卖、订单明细是否完整、小票是否重单。第二是打印格式验证:把小票内容输出成文本文件,和打印机的实际效果逐一比对,重点检查中文对齐和金额精度。第三是状态机回归测试:写一个单测,遍历 OrderStatus 的 canTransitionTo 方法,把所有合法与非法流转都断言一遍,防止后面加功能时破坏了状态约束。
扩展方向上,这个系统最值得优先做的是消息通知和退款。用户下单支付后,可以用企业微信或钉钉机器人把“待打单订单号”推送给食堂窗口,避免窗口一直盯着屏幕刷新。退款则要加一张 refund_record 表,关联原订单,记录退款金额、退款人、退款原因,同时把 orders 状态从 PRINTED 改成 REFUNDED,这需要给枚举增加一个新状态。这两个功能加完后,食堂对账会轻松很多。
我的一个小习惯是:先写状态机和打印日志表,再写页面。状态机和 print_log 是整个系统最容易出鬼的地方,它们稳定了,后面加什么功能都不会伤筋动骨。如果你拿到一个现成的源码包,也建议先看这两个文件:如果状态只有四个字段里拼凑的,没有枚举约束,尽早重构;如果打印日志表不存在,打单功能一定会在某个中午突然崩掉。这套方向值得做,规模小、收益直接,但一定要把那几张表设计好。希望帮到你。
本文还有配套的精品资源,点击获取