ARTICLE DETAIL

资讯详情

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

Java食堂订餐打单系统实战:订单事务、并发扣库存与ESC/POS打印全解析

Java食堂订餐打单系统实战:订单事务、并发扣库存与ESC/POS打印全解析 简介基于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、窗口接单到11已接单后厨开始制作到2、窗口拒单到42制作中出餐完成到33已出餐无终态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. 校验菜品存在且上架 ListDish 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唯一索引就是兜底。rollbackForException.class必须写因为Spring的Transactional默认只回滚RuntimeException业务异常BizException如果不是RuntimeException子类检查异常抛出时事务不会回滚订单就落库了。3.2 扣库存的一条SQL并发下不超卖的关键扣库存最怕超卖100份菜200个人同时下单最后库存变成负数。新手容易写成“先select查库存在Java里减1再update”这个做法在并发场景下必然翻车两个请求同时读到stock5各扣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, ListOrderItem 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是后厨打印机的局域网IP9100是大多数热敏打印机默认支持的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(该订单已补打一次请勿重复操作); } // 生成补打记录printType1 表示补打 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这会锁住该订单的打印记录行直到事务结束。后厨连点补打时第二个请求会在这一行上等待第一个请求打印完成、事务提交后第二个请求读到的是最新的printCount2直接抛出异常。打印记录先落库、再触发打印。先打票再写log会出现打印成功、记录丢失的假失败导致后厨误以为没打到又补一张操作流程上要把“落库”作为补打动作完成的标志。5. 常见问题与避坑记录5个让后厨炸锅的真实场景这类系统我前后写过两个版本第一个版本给食堂用被后厨阿姨追着骂过。遇到的坑集中在并发、编码、事务这三个方向。下面按“现象→原因→解决”列出来都是可以直接对标自己代码排查的经验。5.1 打印卡在事务里账扣了、票没出后厨来吵架现象用户下单后系统提示下单成功但后厨小票机一直空着再看数据库订单状态还是“已预订”重试一次就多生成一单。原因下单方法里直接同步调了打印打印机网络超时数据库连接被事务占用订单一直不提交库存被预扣但无法释放。解决把打印调用从事务里挪出来用TransactionSynchronizationManager的afterCommit回调事务提交后再触发打印。同时给打印加上超时时间Socket连接超时设2秒读超时设3秒不让打印机拖垮整个下单接口。5.2 库存扣成负数先查再扣的脏读翻车现场现象100份的红烧肉高峰期卖出120单库存字段变成负数后厨炒不出来订单还全都显示成功。原因先SELECT stock在Java里判断库存够不够再UPDATE。两个请求同时读stock100都在内存里减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是requestIdvalue是orderNo过期时间设5分钟。5.5 补打重票接口没有幂等后厨被票淹没现象小票丢了后厨点了三次补打打印机连着出三张一样的票。原因补打接口只做了“查订单→拼小票→发给打印机”完全没记录“这单打过几次”。任何一次点击都是一次新的打印。解决给每个订单维护print_log表补打前用SELECT FOR UPDATE锁住打印记录行打印次数超过限制就拒绝。补打逻辑在4.3已经给出核心是让打印动作有状态、可追溯。6. 把打单从同步阻塞改成队列削峰高峰期不再卡死食堂11:30到12:15是订单峰值如果每单都同步往打印机写网络数据后厨打印机串口会排队下单接口的响应时间会被拖到几秒。我一般会给打印单独加一个有界队列下单接口只负责把订单号丢进队列由后台单线程消费者串行取单、调用打印机。Component public class PrintTaskConsumer { private final BlockingQueueString 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的活我都规规矩矩放到事务提交之后。这条经验比任何设计模式都值钱希望帮到你。本文还有配套的精品资源点击获取
返回列表