ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Java的食堂订餐打单系统:状态机、并发扣库与打印队列实践

基于Java的食堂订餐打单系统:状态机、并发扣库与打印队列实践 简介一套基于Java开发的食堂订餐与打单系统设计源码面向校园食堂运营场景或JavaWeb初学者模拟订餐流程并将订单快速生成打单信息提升日常管理效率。项目以10个Java源文件作为业务核心处理订餐、订单生成与打印等逻辑4个XML配置文件和1个properties属性文件管理数据库连接、系统参数等运行配置3个SQL脚本用于建库与数据维护JFreeChart图形文件则将统计结果可视化方便决策。压缩包仅107KB共25个文件同时附有Markdown文档、Git忽略文件、许可证便于代码阅读、版本协作与二次开发。该资源已有245人浏览学习。借助完整源码与配套脚本可清晰梳理订餐、打单、统计等模块的实现思路适合Java课程设计、毕业项目或食堂管理原型参考也可在此基础上拓展菜品、订单状态等管理功能。1. 基于Java的食堂订餐与打单系统菜还没出锅订单已经算清楚了做过企业食堂、学校餐厅或园区团餐系统的人都知道真正的痛点不在“点餐”这个动作而在点完餐之后的“打单、备餐、对账”一气呵成。一个基于Java实现的食堂订餐与打单系统本质上是把“顾客选菜—订单落库—后厨出票—窗口取餐—日终对账”这条链路里的每一环变成可回滚、可追踪、可统计的状态流转。这类系统的核心价值有两个一是用状态机把订餐流程锁死避免超卖和重复下单二是用打印模板和队列机制把后厨单据稳定输出不丢单、不重单。适合正在做毕业设计、年饭系统二次开发或想把老式刷卡订餐替换成浏览器订餐的Java工程师。接下来要讲的做法不依赖某套运维开箱即用的框架而是用Spring Boot JPA Thymeleaf把整个思路从ER图一直推到打印机出纸。2. 订餐核心流程的状态机设计与并发控制方案2.1 为什么订餐流程必须用状态机管起来食堂订餐的典型流程是用户下单 → 支付/扣款 → 后厨接单 → 备餐完成 → 取餐核销。如果不把这个流程建模成状态机代码里就到处都是if (order.getStatus() 1)这样的魔法值判断每加一个环节都要改业务逻辑排错时也看不清订单卡在哪一步。把订单状态定义成枚举通过状态流转表来约束动作是最稳妥的做法。public enum OrderState { // orderState: 10已下单 20已支付 30已接单 40已出餐 50已取餐 60已取消 CREATED(10, 已下单), PAID(20, 已支付), ACCEPTED(30, 后厨已接单), READY(40, 已出餐), COMPLETED(50, 已取餐), CANCELLED(60, 已取消); public final int code; public final String desc; OrderState(int code, String desc) { this.code code; this.desc desc; } public static OrderState fromCode(int code) { for (OrderState state : values()) { if (state.code code) return state; } throw new IllegalArgumentException(非法订单状态: code); } }状态用code而不是枚举的name()一是数据库排序、索引更方便二是前端下拉框展示时直接映射{10:已下单}这类字典数据省了前后端反复翻译。fromCode方法在从数据库读state字段时统一转枚举后续业务代码里就不应该再出现裸数字。这是一个很小的设计点但能把类型安全拉满。2.2 状态流转表与非法动作拦截状态机不只是把状态列出来还得定义“谁允许跳到谁”。订餐系统里最常见的非法流转是已取消的订单被支付回调激活、已取餐的订单被后厨重复打单。当前状态允许动作目标状态已下单支付已支付已下单取消已取消已支付后厨接单已接单已支付取消已取消已接单出餐已出餐已出餐取餐核销已取餐有了这张表Service层就可以统一写一个transition(order, target)方法把状态校验收敛到一个地方。后厨扫码接单、窗口核销取餐都走同一个入口。Service RequiredArgsConstructor public class OrderStateMachine { private static final MapOrderState, SetOrderState ALLOWED new EnumMap(OrderState.class); static { ALLOWED.put(OrderState.CREATED, EnumSet.of(OrderState.PAID, OrderState.CANCELLED)); ALLOWED.put(OrderState.PAID, EnumSet.of(OrderState.ACCEPTED, OrderState.CANCELLED)); ALLOWED.put(OrderState.ACCEPTED, EnumSet.of(OrderState.READY)); ALLOWED.put(OrderState.READY, EnumSet.of(OrderState.COMPLETED)); } Transactional public Order transition(Long orderId, OrderState target, String operator) { Order order orderRepository.selectForUpdate(orderId); if (order null) { throw new BizException(订单不存在); } OrderState current OrderState.fromCode(order.getState()); if (!ALLOWED.getOrDefault(current, Collections.emptySet()).contains(target)) { throw new BizException(非法状态流转: current.desc - target.desc); } order.setState(target.code); order.setUpdateBy(operator); return orderRepository.save(order); } }selectForUpdate是数据库层SELECT ... FOR UPDATE的行级锁这是食堂订餐场景下保证状态不被并发修改的基础。这里不用乐观锁的原因是食堂订餐的单量峰值通常发生在开饭前的十几分钟同一时刻多个窗口同时点餐订单行锁冲突的概率远小于数据被多个事务同时修改的概率悲观锁持有时间极短回滚成本更低。如果你所在系统是分布式、订单量大到行锁成为瓶颈再换成乐观锁版本也不迟——但一个小型订餐系统用悲观锁稳定性和代码可读性都能兼顾。2.3 下单扣减库存的幂等与超卖防护食堂菜品的库存不是传统意义上的“仓库库存”而是当日可售份数。后厨备菜时设定每种菜今日做多少份卖完即止。这个场景需要防超卖两个用户同时下单最后一份红烧肉只能有一个人成功。UPDATE dish_stock SET remaining remaining - #{quantity} WHERE dish_id #{dishId} AND remaining - #{quantity} 0 AND meal_time #{mealTime}这条 SQL 把“检查库存 扣减库存”合并成一个原子操作WHERE条件里直接判断扣减后不会为负。如果更新返回的影响行数为 0说明库存不足或菜品已售罄业务层就可以捕获这个结果并回滚事务。这种写法比“先 select 再 update”在高并发下安全得多也是很多人在准备 Java 面试时被反复追问的“并发扣减库存”的标准答案。要注意的是meal_time一定要放进条件里否则会出现午餐把晚餐库存扣掉的隐蔽 bug。3. 订单数据模型与ER图设计从表结构说起3.1 订单主表与订单明细表怎么拆食堂订餐的订单结构并不复杂但拆表的方式直接决定了后厨打单的效率。最常见的做法是分两张表orders订单主表和order_items订单明细表。主表记录订餐人、总金额、状态、创建时间明细表记录每道菜的菜名、价格、数量。一单多菜一对多的关系ER 图里就是一个常规的1:N关联。CREATE TABLE orders ( id bigint PRIMARY KEY AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL COMMENT 订餐人ID, meal_time tinyint NOT NULL COMMENT 餐次: 1早餐 2午餐 3晚餐, total_amount decimal(10,2) NOT NULL COMMENT 总金额, state int NOT NULL DEFAULT 10 COMMENT 订单状态见OrderState, remark varchar(255) DEFAULT NULL COMMENT 备注(忌口等), create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_meal (user_id, meal_time), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订餐主表;order_no要用唯一索引并且生成时必须带时间戳和随机因子避免并发生成相同订单号。常见做法是yyyyMMddHHmmss 四位随机数 用户ID后四位这样在数据库里按订单号就能看出订餐时间省得查明细时还要 join 时间字段。idx_user_meal用来支撑“查某人某天订过什么”的高频查询比如用户端显示历史订单、管理端统计个人饭补这个联合索引能直接覆盖。3.2 订单明细表为什么要冗余菜品快照CREATE TABLE order_items ( id bigint PRIMARY KEY AUTO_INCREMENT, order_id bigint NOT NULL COMMENT 订单主表ID, dish_id bigint NOT NULL COMMENT 菜品ID, dish_name varchar(64) NOT NULL COMMENT 菜品名称快照, price decimal(10,2) NOT NULL COMMENT 单价快照, quantity int NOT NULL DEFAULT 1 COMMENT 数量, KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;明细表里的dish_name和price是冗余字段这在设计上是有意为之的。食堂的菜品价格会调整端午节加收 1 元服务费、猪肉涨价后菜品调价如果明细表只存dish_id去关联菜品主表历史订单的金额对账就会变得困难打印旧订单回执时价格也可能显示成了新价格。把菜名和单价在生成订单时快照进明细表以后无论是补打票据还是月度对账看到的数据都是下单那一刻的真实数据。这个思路在电商订单设计里是标配但在很多自行开发的食堂订餐系统里却被省掉了等到对账出问题再回来补就晚了。3.3 数据库事务边界与查询接口的取舍下单接口的事务边界要覆盖三件事插入主表、插入明细表、扣减库存。这三步要么全部成功要么全部失败否则就会出现订单写了但库存没扣的数据脏账。在OrderServiceImpl的Transactional方法里顺序是先扣库存再插入订单。理由是如果先插入订单再扣库存时发现库存不足事务回滚会把订单插入也撤销表面上没问题但库存扣减的 SQL 如果用的是前面那种“条件更新”它是原子操作不会受事务回滚影响所以先扣库存反而能快速失败——库存不够时直接抛出异常订单根本没机会写进去省去一次回滚开销。管理端经常要查“某天每个菜的销售量”不要直接 join 订单明细表扫全表而是单独维护一张daily_dish_sales统计表在下单事务里同步累加。查询端永远只查这张统计表响应时间是毫秒级。统计表只存meal_time、dish_id、total_quantity三个字段冗余度低数据实时性完全够用。如果你用的是 JPA可以在仓储接口上用ModifyingQuery写原生更新语句绕过一级缓存确保扣库存的 SQL 在事务里立即执行。4. 打单系统的落地姿势打印模板、队列与重试机制4.1 打单打印什么后厨联单的排版逻辑“打单系统”在食堂场景里核心产物是给后厨的取菜单和给用户的取餐单。取菜单通常按餐桌分组食堂团餐或按订单号逐个打印成本低、速度快。排版的要点是菜品名、数量、备注必须醒目编号要按“订餐时间 窗口号”生成方便后厨按顺序备餐。用 Thymeleaf 模板引擎渲染 HTML 再转 PDF 或直接走浏览器打印是比较省事的方案。HTML 模板的好处是后端不需要依赖额外的报表组件CSS 就能控制排版。以下是一个简化版的后厨取菜单模板!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8/ title后厨取菜单/title style body { font-family: Microsoft YaHei, sans-serif; width: 80mm; } .header { text-align: center; font-size: 14px; font-weight: bold; } .item { font-size: 12px; margin: 4px 0; } .remark { color: red; font-size: 10px; } .footer { margin-top: 8px; font-size: 10px; color: #666; } /style /head body div classheader后厨取菜单 - 午餐/div div classitem th:eachitem : ${order.items} span th:text${item.dishName}/span span th:textx ${item.quantity}/span div classremark th:if${! #strings.isEmpty(item.remark)} th:text${item.remark}/div /div div classfooter th:text订单号: ${order.orderNo}/div /body /html这个模板定义了 80mm 宽的小票样式兼容常见的热敏打印机。模板中并没有用复杂的 JavaScript 或图片目的是保证打印机的兼容性。Thymeleaf 渲染完成后通过 Spring 的ModelAndView或TemplateEngine.process(templateName, context)得到字符串再通过浏览器 window.print() 或者后端调用打印组件输出。如果只是局域网内部用浏览器打印是成本最低的选择如果需要无人值守自动出票就要在后端维护一个带优先级和重试次数的打印队列。4.2 打印队列的实现思路分离主流程与出票流程在真实的食堂场景里用户点完餐之后如果直接同步触发打印机一旦打印机离线或卡纸整个下单事务会被拖住。常见的做法是把“订单落库”和“打印出票”分离。订单落库是主流程必须强一致打印是次流程允许异步、允许失败后重试。Component public class PrintQueue { private final BlockingQueueOrderPrintTask queue new LinkedBlockingQueue(2000); private final MapString, Integer failCount new ConcurrentHashMap(); private final ThreadPoolExecutor printerExecutor new ThreadPoolExecutor( 1, 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), new ThreadPoolExecutor.CallerRunsPolicy() ); public void submit(OrderPrintTask task) { if (!queue.offer(task)) { log.warn(打印队列已满丢弃任务: {}, task.getOrderNo()); return; } printerExecutor.submit(this::consume); } private void consume() { try { OrderPrintTask task queue.poll(2, TimeUnit.SECONDS); if (task null) return; boolean printed printerService.print(task.getTemplate(), task.getData()); if (!printed) { retry(task); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void retry(OrderPrintTask task) { int fails failCount.merge(task.getOrderNo() : task.getTicketType(), 1, Integer::sum); if (fails 3) { queue.offer(task); } else { failCount.remove(task.getOrderNo() : task.getTicketType()); log.error(打印重试3次仍失败需要人工干预: {}, task.getOrderNo()); } } }这个队列的实现要点有三个。第一BlockingQueue容量设置成 2000队列满时直接丢弃任务并记录日志而不是无限阻塞主线程因为打印失败可以补打但下单接口绝对不能卡死。第二CallerRunsPolicy作为拒绝策略线程池满载时由调用线程直接执行这样打印任务不会在队列积压时无限往后拖延。第三重试次数控制在 3 次超过 3 次就不再自动重试而是记录错误日志等待人工处理每次重试之间没有 sleep是因为打印机离线时 sleep 只是白等重试间隔放在printerService.print内部做超时控制更合理。这里的printerService.print在真实项目里一般会走 HTTP 请求到一个打印中间服务比如常诚、佳博打印机的局域网 SDK接口超时设为 5 秒避免打印机 TCP 连接长时间占住线程。很多从零写打单系统的人忽略了这个超时控制结果打印机拔电后打印队列把整个线程池的线程都耗光订餐主流程跟着雪崩。4.3 后厨大屏与打单的配合现在不少新一代食堂不再把纸质单直接送后厨而是用一个后厨大屏展示待备餐列表。打单系统打印的纸质单反而留给用户作为取餐凭证后厨看大屏按单备餐。这种情况下打印队列仍然保留但printerService.print从“驱动打印机”换成“推送WebSocket消息”或“只更新Redis中的待备餐列表”。这个变化只需要替换PrintQueue里的printerService实现队列和重试逻辑完全复用这就是把打印任务抽象成队列的好处。5. 从源码工程到可运行部署环境配置与三条必调参数5.1 工程骨架与本地运行的最小配置这个项目的源码拿到手之后常见的结构是maven工程按controller/service/repository/entity分包。本地跑起来之前先把application.yml里的数据源和打印服务地址检查一遍。spring: datasource: url: jdbc:mysql://localhost:3306/canteen?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghairewriteBatchedStatementstrue username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 5000 jpa: hibernate: ddl-auto: update show-sql: false open-in-view: false thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html printer: endpoint: http://192.168.1.200:9100/print timeout-seconds: 5 retry-times: 3hikari.maximum-pool-size在食堂订餐场景里不建议开大。开饭高峰并发一般在几百 TPS 以内20 个连接足够用连接数开多了反而增加数据库的上下文切换开销。connection-timeout设置成 5000ms数据库出现问题时不至于让用户请求无限等待。open-in-view建议设置为false避免每次 HTTP 请求都占用一个数据库会话直到视图渲染完这个参数在很多老 SonarQube 规则里也是必报的坏味道。5.2 三个必须调对的参数事务超时、打印重试、线程池拒绝策略第一Transactional事务默认超时没有限制食堂订餐场景里一旦某个环节锁住了比如SELECT FOR UPDATE等锁超过 30 秒事务不会自动回滚用户端表现为页面一直转圈。因此建议在订单 Service 方法上显式声明超时Transactional(timeout 10) public Order createOrder(CreateOrderCommand cmd) { // 扣库存、插单、写明细 }第二打印重试参数 3 次不是拍脑袋。超过 3 次大概率是打印机离线、缺纸或驱动挂了再试下去只是浪费队列资源不如先在数据库把print_status标记为失败让管理员在管理后台一键补打。print_status字段建议设计成0 待打印、1 打印成功、2 打印失败。第三线程池拒绝策略选CallerRunsPolicy而非AbortPolicy前面已经解释过。如果你用的是默认的AbortPolicy线程池满了直接抛RejectedExecutionException在异步打印这个场景下可能会导致后台线程死掉、后续任务再也不会被消费。5.3 上线后验证打单链路是否可靠的三步检查部署完成之后不要急着在线下环境只看页面是否通按下面三步在生产环境做最小化冒烟验证。第一步用curl直接调用打印接口绕过订餐页面确认打印机端到端是通的curl -X POST http://localhost:8080/api/printer/test \ -H Content-Type: application/json \ -d {ticketType:KITCHEN,content:测试打印-午餐订单} \ --connect-timeout 3 --max-time 10第二步在数据库手动把一条订单的state从 10 改成 20然后调用打单接口确认状态机允许从已支付流转到已接单且明细表数据能正确渲染进打印模板。这一步验证的是状态机与打印模板之间的数据闭合。第三步拔掉打印机网线下一笔真实订单观察后台日志里会出现一次重试、两次重试直至三次之后打上“需要人工干预”日志然后再把网线插回去确认后续订单打印恢复。不要跳过第三步很多系统的打印逻辑是“假异步”——主线程 sleep 等待打印结果一旦打印机故障订餐接口跟着超时是最危险的现场事故。补充一个贴近实际的小技巧订餐系统的order_no建议在模板里用 Code 128 生成条形码而不是 QR 码。小票宽度有限条形码对横向空间的利用率更高后厨扫码枪读取也更稳定。生成条码不需要引额外依赖用 Google ZXing 的Code128Writer即可代码量不到十行出图后直接用img嵌入模板热敏纸打印出来效果干净利落。这类细节和上面三点一样决定了一个订餐打单系统是从“勉强能跑”变成“每天靠得住”。本文还有配套的精品资源点击获取
返回列表