ARTICLE DETAIL

资讯详情

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

Java食堂订餐与打单系统:并发扣库存、打印避坑全链路设计

Java食堂订餐与打单系统:并发扣库存、打印避坑全链路设计 简介面向校园食堂运营管理者与Java学习者这份源码完整呈现订餐、订单管理到打单的闭环业务设计。项目基于Java实现利用面向对象特性构建核心逻辑适合作为课程设计或小型企业级项目入门参考。压缩包共25个文件含10个Java源文件、4个XML配置文件、3个JFreeChart图形文件、3个SQL脚本、3个Markdown文档及Git忽略、协议与属性文件整体约107KB结构清晰、便于按模块研读。其中图表文件可将统计结果直观展示SQL脚本负责数据库建表与初始数据提升了项目的完整度和实用性。已有245人学习浏览内容覆盖编码、配置、文档与数据库管理等关键环节读者可从中获取一套可运行、可扩展的系统设计思路与工程实践范本对食堂订餐场景的数字化提效具有直接参考价值。1. 基于Java实现的食堂订餐与打单系统一场挪到手机里的排队长龙中午十一点半食堂排队的不止是人还有后厨那台出票机。很多初学Java的人以为食堂订餐系统就是把菜品列在网页上真正做完才发现从用户点餐到后厨打出小票这中间隔着订单状态、库存扣减、打印重试三道坎每一道都能让项目在演示当天翻车。基于Java实现的食堂订餐与打单系统设计源码核心就是让“用户点餐、后台接单、后厨打单”三条链路完整跑通用户在手机上选餐下单后厨窗口自动打出一张带工号或桌号的小票备餐和取餐不再靠吼。这套方案适合三类人正在做Java课程设计的学生、要给中小食堂做内部系统的开发以及想从单体模块跳进全链路设计的初学者。2. 系统该拆成几块从单页 CRUD 到两条主链路2.1 技术选型Spring Boot MyBatis Thymeleaf 为什么够用常见的做法是用 Spring Boot 搭骨架MyBatis 管数据库Thymeleaf 做后台管理页。选这套组合不是因为它新而是因为它刚好卡在“够用”和“不复杂”之间。Spring Boot 自带内嵌 Tomcat 和 Scheduled 定时任务打单轮询不需要再引 QuartzMyBatis 让你手写 SQL扣库存这种事用一句 UPDATE 就能精确控制比 JPA 在复杂更新场景下来得直白Thymeleaf 服务端渲染管理端页面直接写模板不用为了一个内部系统单独搭 Vue 工程。如果点餐端要做到手机上常见方案是单独做一个 H5 页面走 REST 接口后端只返回 JSON管理端和打单服务继续用 Thymeleaf 渲染。这样前后端职责分得开又不至于为了“前后端分离”这个名头把工程复杂度抬高三倍。新手容易犯的错是一上来就拆微服务、上 Redis一个课设体量的项目根本撑不起这么多中间件反而把订单状态和打印状态的数据一致性搞得很难调试。2.2 模块边界点餐端、管理端、打单服务各管什么这套系统拆成三个模块边界要画清楚。用户点餐端负责菜品展示、下单、支付回调如果是模拟支付就跳过后台管理端负责菜品上下架、库存调整、订单查询和手动补打打单服务是独立的一块它轮询数据库里“待打印”的订单拼装小票指令发送到后厨打印机再把结果回写。三个模块之间不要直接互相调方法。点餐端写完订单后只向打印队列表插入一条记录打单服务自己去捞管理端补打也只是把打印记录的状态重置成“待打印”。这样打印机掉线、卡纸都不会影响用户下单——用户端的红线是“下单必须成功”打单可以失败重试。2.3 主链路时序一个订单怎么走到后厨窗口把时序捋清楚后面写代码才不会乱。用户提交订单后后端先做库存扣减扣减成功则写入订单主表和订单明细表同时向打印日志表插入一条“待打印”记录事务提交。打单服务每 2 到 3 秒查一次打印日志表捞到待打印记录后先置为“打印中”拼装 ESC/POS 指令发送到打印机成功置为“打印成功”失败置为“打印失败”留待重试。这里最关键的设计是“先抢状态再干活”。打单服务可能部署多实例也可能定时任务重叠执行如果不先把记录标记为“打印中”两台进程会同时捞到同一条记录后厨就出两张一模一样的票。目录结构按模块分包和这个时序是对应的com.canteen ├── controller # 点餐端 REST 接口 管理端页面路由 ├── service # 订单服务、菜单服务、打印服务 ├── mapper # MyBatis 接口 ├── entity # Order、MenuItem、PrintLog 实体 ├── printer │ ├── PrinterClient # 串口/网口打印机通信封装 │ └── TicketBuilder # 拼装小票字节指令 ├── config # 数据源、定时任务、打印机参数配置 └── task └── PrintTask # 定时轮询待打印订单目录这样拆本质是把“面向对象编程”里的单一职责落下来每个类只干一件事演示的时候也好讲——评审老师问“打印模块在哪”直接指 printer 包和 task 包就行。不建议把打印逻辑塞进 Controller 或者 Service 里后面你会发现在代码里找一张小票的格式定义比登天还难。3. 数据库表设计六张表撑起订餐和打单的流转3.1 核心表结构与建表 SQL数据库是这套源码真正花心思的地方。六张表足够覆盖一个中小食堂的场景菜单分类表、菜品表、用户表或用工号代替、订单主表、订单明细表、打印日志表。菜品表里直接放 stock 库存字段简单场景不用单独建库存流水表但下单时扣库存必须用乐观锁下一节细说。建表 SQL 我习惯这样写字段注释直接写清楚避免三个月后自己都看不懂CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 分类名如大荤/小荤/素菜, window_no VARCHAR(10) DEFAULT NULL COMMENT 对应后厨窗口号打单时拼接, sort INT DEFAULT 0 COMMENT 排序权重小的排前面 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE menu ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL COMMENT 菜品名小票打印用, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 100 COMMENT 当日库存扣完即下架, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE COMMENT 工号/卡号小票上显示, name VARCHAR(50) NOT NULL ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号不用自增id, user_id INT NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待接单 1制作中 2可取餐 3已取餐 4已取消, remark VARCHAR(200) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, create_time) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, menu_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10, 2) NOT NULL COMMENT 下单时快照价格菜单改价不影响旧单 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE print_log ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL UNIQUE COMMENT 一单一条打印记录, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待打印 1打印中 2成功 3失败, print_count INT NOT NULL DEFAULT 0 COMMENT 累计打印次数超过3次报警, last_print_time DATETIME DEFAULT NULL, error_msg VARCHAR(500) DEFAULT NULL ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;订单明细表和打印日志表是这套设计的两个关键。明细表的作用是把“一单只点一个菜”扩展成“一单能点多个菜”不用改表结构打印日志表保存每次出票的状态和错误信息它是排查“为什么后厨没出票”的唯一依据。3.2 订单状态和打印状态为什么分开新手最容易犯的错是企图用一个 status 字段同时表达“用户的饭做到哪一步了”和“小票打没打”。这两个状态变化的节奏完全不同订单状态跟着人走用户取餐后才终态打印状态跟着设备走打印机一切 paper out 就可能回退。orders.status 表达业务进度取餐是终点print_log.status 表达设备任务进度成功或失败是终点。两张表通过 order_id 一对一关联管理端查订单明细时可以 JOIN 出“这个订单小票打了没、打了几次”打单服务只关心 print_log不碰 orders。这个拆分会让打单重试的代码变得很干净重试时只改 print_log.status不影响用户看到的订单状态。3.3 库存字段放哪和扣减语义menu.stock 在食堂场景里不是真实库存更像“当日可售配额”。大锅菜没法精确到克但可以提前设定“今日红烧肉 100 份”卖完就显示售罄。这个字段不需要单独建表是因为它的生命周期只有一天——食堂次日开门时定时任务重置即可。真正的难点是扣减语义。更新语句必须带条件 stock 要扣的数量而且要用一条 UPDATE 原子完成不能先 SELECT 再 UPDATE否则并发场景下两个请求同时读到 stock1各扣一份库存就变负数了。这点放到下一章展开表设计阶段只要知道 stock 字段必须是数值类型、不能有符号、SQL 里别写 SELECT 出来再减就行。4. 下单与库存扣减并发热潮下的订单落库4.1 下单接口的事务编排与代码骨架点餐端最核心的接口是提交订单。Service 层代码要表达清楚三个动作扣库存、写订单、插打印任务。用 Spring 的 Transactional 把前两个包进同一个事务打印任务也在这个事务里插入但打印机真正的 IO 绝对不能出现在事务里——这是后面避坑章第一条要展开的教训。Service public class OrderServiceImpl { Autowired private MenuMapper menuMapper; Autowired private OrderMapper orderMapper; Autowired private PrintLogMapper printLogMapper; Override Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long menuId, Integer quantity) { // 1. 乐观锁扣库存返回 1 成功返回 0 表示库存不足或并发冲突 int rows menuMapper.deductStock(menuId, quantity); if (rows 0) { throw new BizException(手慢了这道菜已售罄); } // 2. 生成业务订单号并写入订单主表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(menuMapper.selectPrice(menuId).multiply(BigDecimal.valueOf(quantity))); order.setStatus(0); orderMapper.insert(order); // 3. 写入订单明细价格用下单时的快照 OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setMenuId(menuId); item.setQuantity(quantity); orderItemMapper.insert(item); // 4. 插入打印任务status0 表示等待打单服务轮询 PrintLog printLog new PrintLog(); printLog.setOrderId(order.getId()); printLog.setStatus(0); printLogMapper.insert(printLog); return order; } }逻辑说明第 1 步扣库存是并发控制的关键第 4 步插入打印任务只是“登记”真正调用打印机的是另一个定时任务。这么排布的好处是用户下单的响应时间不会被打印机拖住——打印机卡纸、离线、串口被占用最多影响打单重试不影响订单落库。参数说明Transactional(rollbackFor Exception.class) 表示任何异常都回滚包括我们自定义的 BizExceptionmenuMapper.deductStock 返回 int 是 MyBatis 的 update 影响行数不是菜品 ID。如果你用的数据库是 MySQL InnoDB默认隔离级别可重复读这个场景下事务内两条 UPDATE 之间不会存在幻读问题。4.2 防止超卖一条 UPDATE 的乐观锁扣库存的 SQL 写法比 Controller 层代码重要得多。正确做法是把判断放在 UPDATE 的 WHERE 条件里update iddeductStock UPDATE menu SET stock stock - #{quantity} WHERE id #{menuId} AND stock #{quantity} /update这条 SQL 由数据库的行锁保证原子性。两个请求同时执行时只有一个能拿到行锁另一个等锁释放后 WHERE 条件已经不满足影响行数为 0抛出“已售罄”。这是最便宜且有效的防超卖方案。参数说明quantity 必须在 Service 层做非空校验和小于 99 的校验否则一次下单把库存扣成负数虽然有 stock 条件兜底但会给后面的对账带来麻烦。另外菜单价格不要在前端传后端起因是因为订单主表的 total_amount 由服务端自己算前端传价钱的接口一律不信任这是做这类系统的基本素养。4.3 订单号生成不用数据库自增的原因订单号用自增 ID 有问题一是用户能看到今天一共多少单容易被刷单接口探测二是多实例部署时自增 ID 冲突。常见做法是拼一个 32 位以内的业务号日期时间 用户ID后四位 4 位随机数 场景标识。public String generateOrderNo() { String date LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); String userPart String.format(%04d, userId % 10000); String random String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); return date userPart random; }逻辑说明订单号不是全局唯一性最强的方案但在这个体量下够用。orders.order_no 字段上有 UNIQUE 约束万一极端碰撞数据库会报 DuplicateEntry由全局异常处理器转成“请重试”返回即可。比 UUID 好看小票上师傅一眼能念出来。4.4 事务边界为什么打印机不能出现在事务里这是整套源码里最容易踩坑的设计决策。把打单放进下单事务代码会短几行但后果是打印机一卡纸用户下单也跟着超时严重时 Connection 池被打满整个点餐系统瘫痪。正确边界是下单事务只负责写数据不负责做 IO。打印机调用放到定时任务里任务先查 print_log再调打印机最后更新状态。这样打印失败只影响 print_log 的状态用户无感知后台能看到“打印失败”然后补打。注意任何外部设备调用打印机、短信、邮件都不要放进数据库事务。设备 IO 是不可控的事务必须只覆盖数据库操作这条规则到了生产环境能救你一命。5. 打单模块实现与避坑订单变成后厨小票的最后一米5.1 打单方案选型串口、网口还是共享打印食堂后厨的小票打印机主流是 58mm 热敏打印机接口有三种接法。串口COM 或 USB 转串口最可控指令直达不受 Windows 驱动影响网口打印机走 TCP 9100 端口适合部署在 Linux 服务器上远程控制Windows 共享打印机最省事但共享链路由 Windows 驱动接管指令控制能力受限容易出现格式偏差。我一般推荐串口或网口原因在后文避坑里会展开。Java 里操作串口老项目用 javax.comm但这个库已经停止维护且不支持 64 位 JDK新项目常见做法是用 jsscJava Simple Serial Connector。引入依赖后配置类里集中管理串口号、波特率、字符集三个参数方便现场部署时改配置。5.2 用 Java 拼装 ESC/POS 指令最小可用打单代码小票打印本质是往打印机串口写字节。ESC/POS 指令集里最常用的三条指令是初始化打印机、输出文本、切纸。中文小票的文本要按打印机支持的编码转成字节国内多数热敏打印机出厂默认 GBKLinux 下调试最常见的坑是把 Java 默认 UTF-8 字节直接发给打印机。public class TicketBuilder { private static final byte[] INIT new byte[]{0x1B, 0x40}; // ESC 初始化 private static final byte[] FEED new byte[]{0x1D, 0x56, 0x42}; // GS V B 进纸切纸 public byte[] build(OrderVO order, String charset) throws UnsupportedEncodingException { ByteArrayOutputStream os new ByteArrayOutputStream(); os.write(INIT); // 按行拼接小票内容标题用大字明细用常规字 appendLine(os, 食堂订餐, 2, charset); // 2 代表倍宽倍高 appendLine(os, 工号: order.getCardNo(), 0, charset); appendLine(os, 菜品: order.getMenuName(), 0, charset); appendLine(os, 数量: order.getQuantity(), 0, charset); appendLine(os, 金额: order.getTotalAmount(), 0, charset); os.write(FEED); return os.toByteArray(); } private void appendLine(ByteArrayOutputStream os, String text, int size, String charset) throws UnsupportedEncodingException { if (size 0) { os.write(new byte[]{0x1D, 0x21, (byte) size}); // GS ! n 设置字符大小 } os.write((text \n).getBytes(charset)); if (size 0) { os.write(new byte[]{0x1D, 0x21, 0x00}); // 恢复默认字号 } } }逻辑说明整个方法不直接依赖打印机型号只拼标准 ESC/POS 字节流。文本行先按 charset 编码成字节再写入切纸指令放在最后保证每张票都在完整内容结束后切断。用 ByteArrayOutputStream 而不是 String 拼接是因为打印机只认字节不认字符。参数说明charset 一般配 GBK 或 UTF-8必须和打印机内部字库一致从 application.yml 读取0x1D 0x21 是设置字符倍宽倍高的指令写成 2 就是两个字符宽的标题。如果用了不支持的指令打印机会把它们当作普通文本打出来纸上出现 “[]” 之类的乱码这就是排查指令问题时最直观的症状。5.3 踩坑一小票中文乱码编码成了黑匣子现象后厨打出来的小票上中文全是“锟斤拷”或者问号英文和数字正常。原因Java 字符串默认 UTF-858mm 热敏打印机出厂字库多为 GBK直接把字符串.getBytes() 默认字符集发给打印机编码就对不上。解决配置文件里单独设 charsetGBK拼装时统一 getBytes(GBK)不要用系统默认字符集。这个坑在开发机上不明显因为开发时用的打印模拟器多半按 UTF-8 解码到了现场接真机才暴露。我的习惯是打印机参数表里固定增加 charset 字段安装时先打印一张测试页看中文是否正常有问题只改配置不改代码。5.4 踩坑二网络超时导致同一张票打出两遍现象后厨出了两张一模一样的票用户只下了一单。原因打单任务调打印机时网络超时任务抛出异常但状态没有被更新为“失败”定时任务下一轮又把这条记录当成待打印捞出来。解决用状态抢占替代状态回查打单前先执行 UPDATE print_log SET status1 WHERE id? AND status0影响行数为 1 才继续打印。int updated printLogMapper.markProcessing(printLog.getId()); if (updated 0) { continue; // 已被其他线程抢占跳过 }这行代码是整套打单模块的后悔药。抢到状态的任务即使后续打印失败也只会把状态改为 3不会出现两个任务同时给一台打印机塞字节的混乱。5.5 踩坑三串口打不开或打印到一半卡住现象程序启动时报 SerialPortException: Port busy或者小票打到一半停止状态停在“打印中”。原因一串口被别的程序占用Windows 下常见是打印机厂商的驱动监控程序Linux 下常见是 modemmanager 自动探测 USB 串口。原因二写入字节过快超出打印机缓存造成指令截断。解决Windows 下卸载或关闭驱动自带的监控服务Linux 下用 systemctl mask 屏蔽 modemmanager写入时每次发送后 sleep 10 到 20 毫秒等打印机消化。5.6 踩坑四菜名太长小票排版整体错乱现象一道“辣椒炒肉配卤蛋加香肠”的菜名印出来直接换行后面的数量和金额对不齐。原因58mm 小票纸一行最多约 16 个汉字菜名不做截断就会顶到第二行破坏对齐。解决在拼接层对菜品名做宽度裁剪按字符长度截到 14 个汉字超过的加省略号金额和数量永远独立成行。裁剪要按字节处理中文两个字节、英文一个字节直接 substring 会把最后一个中文字符劈成半个。private String fitName(String name) { int maxBytes 28; // 14 个汉字的 GBK 字节数 if (name.getBytes(StandardCharsets.GBK).length maxBytes) { return name; } StringBuilder sb new StringBuilder(); int len 0; for (char c : name.toCharArray()) { int n String.valueOf(c).getBytes(StandardCharsets.GBK).length; if (len n maxBytes - 3) break; sb.append(c); len n; } return sb.append(...).toString(); }逻辑说明逐字符累计字节数预留 3 个字节给省略号截断时不会劈开汉字。这样无论后厨点什么奇葩长名价格和数量都能稳稳落在同一列。这个函数看起来小事却是现场运维时被食堂阿姨夸奖最多的改动。6. 上线前的验证技巧接口自测、并发脚本与打印试纸验证这套源码不能只靠点几下页面。我每次提交前固定做三件事。第一件是接口冒烟用 curl 直接打下单接口确认返回的订单号和数据库记录一致curl -X POST http://localhost:8080/api/order/create \ -d userId1menuId3quantity2第二件是并发脚本用一个 20 线程的池子同时打 200 个下单请求打完查 menu.stock 是否正好减少 200。如果库存多扣或少扣说明事务或乐观锁有问题这一点是面试时最值得拿出来讲的验证思路ExecutorService pool Executors.newFixedThreadPool(20); for (int i 0; i 200; i) { pool.submit(() - restTemplate.postForEntity(orderUrl, req, Result.class)); } pool.shutdown();第三件是打印试纸。真机连着串口时先单独打印一张测试页确认中文编码、对齐、切纸都正常再模拟打一张真实订单。如果测试页正常而订单票异常问题基本出在 TicketBuilder 的拼装逻辑而不是打印机配置。这些年下来我最大的教训是食堂订餐系统的稳定性不取决于点了多炫酷的技术而是取决于你把“下单”和“打单”之间的边界划分得多干净。遇到过太多项目死在后厨打印机一卡纸、整个点餐链路跟着瘫掉把打单任务独立出来并加上状态抢占之后系统才算真正能扛住午高峰。这套设计不一定适合所有场景但如果你正卡在并发扣库存和打印重试这两道坎上希望这篇笔记能帮你少走一段弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表