ARTICLE DETAIL

资讯详情

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

食堂订餐与打单系统:从源码到上线的Java实践

食堂订餐与打单系统:从源码到上线的Java实践 简介一款基于Java实现的食堂订餐与打单系统源码面向Java学习者、课程设计者以及校园食堂信息化管理人员可用于模拟订餐流程、生成订单并完成打单操作帮助提升食堂日常运营与数据管理效率。压缩包共25个文件、107KB核心包含10个Java源文件、4个XML配置文件、3个SQL脚本和3个JFreeChart图表文件另有Git忽略文件、属性文件、说明文档与开源协议文件从编码、配置、数据库到文档均有覆盖。已有245人浏览学习。读者可借助完整的源码结构快速理解订餐业务模块划分、订单处理逻辑以及数据统计展示思路SQL脚本能帮助快速建表XML配置便于调整系统参数图表文件则让结果可视化适合作为课程设计、毕业设计或实际食堂管理项目的基础框架也方便进一步扩展预约、统计、报表等功能。1. 食堂订餐与打单系统为什么不建议直接拿现成源码跑Java 课程设计案例源码里食堂订餐与打单系统出镜率很高但把它当普通增删改查写和真正用到食堂后厨是两种工作量完全不同的项目。订餐高峰集中在 11:30 到 12:30学生扎堆下单后厨需要的不是漂亮的用户订单而是按窗口分好类、能贴在小票机上的出餐单。标题里的「订餐」管用户侧「打单」管后厨侧中间靠订单状态串起来。这篇笔记适合三类人拿源码做 Java 课程设计的学生、想给单位食堂自建系统的开发、打算在开源源码上二次改造的运维或后端。我会按模块拆解、表设计、下单与打单代码、三种打单手段、踩坑点、上线前必改的状态机来写。2. 先拆模块订餐、打单、对账是三个独立子系统很多源码把订餐和打单写在一个 Service 里Controller 调一个接口就把订单存了、小票打了、库存扣了。这种写法在演示环境没问题一上真实食堂就出问题打印机卡一下整个下单事务被拖住后厨想按窗口重新排序发现订单结构里根本没有窗口概念。我一般会先把系统拆成三个模块订餐负责生成订单打单负责把订单变成后厨能用的纸质指令对账负责回答「今天到底卖了多少、退了多少、哪几单漏打了」。2.1 订餐模块用户、菜品、订单的边界怎么切订餐模块最少要有四张表用户表、菜品表、订单主表、订单明细表。用户表别只存一个 openid 或学号要把工号/学号、部门或班级、餐别偏好这些字段一起设计进去后续统计「哪个窗口排队最长」时能用上。菜品表要区分菜品和规格比如「红烧牛肉套餐」有大小份价格不同规格应该做成独立字段或者子表而不是塞进菜名里。订单主表和明细表是这套系统的地基。主表只放订单号、用户、总金额、状态、支付渠道这类整体信息明细表放每个菜品、数量、单价、所属窗口。把主表和明细表分开不是为了显得规范而是为了打单。后厨小票要按窗口分组打印明细表里带上窗口编号打单服务按窗口号 group by 一次就能出餐。主表明细合并成一张大宽表的源码我也见过报表能跑但打单逻辑特别别扭。2.2 打单模块后厨凭什么出餐小票和汇总单的差异打单模块容易被人忽略但它是这套系统里和物理世界最近的部分。后厨要两种单据一种是一单一打的出餐票贴在餐盒或托盘上顾客凭号取餐另一种是汇总单比如「12:10 前已订套餐窗口 A 共 45 份、B 窗口 32 份」方便后厨提前备餐。很多系统只实现了第一种后厨师傅只能一张一张数高峰时数漏一张就得多等几分钟。打单的触发时机也要想清楚。如果是食堂先付款后取餐下单成功后立刻打单没问题如果是先订后付打单指令应该放在支付回调里而不是下单接口里。还有一类食堂允许提前一天订次日餐这种场景打单时间不是下单时间而是「取餐时段开始前 30 分钟」。源码里如果有定时任务重点看它是不是按业务时间来扫描的不要按自然日扫。2.3 对账与统计日结、退款、漏单从哪里查对账模块的输入是订单主表、支付渠道流水、打印记录表。常见做法是每天凌晨生成一张日结汇总表内容包括订单总数、支付成功数、退款数、实际出餐数、各窗口出餐明细。最容易踩的坑是直接对订单表做 count 和 sum因为订单表里既有用户取消的又有支付成功但未打印的直接求和会把对账数据带偏。我会额外建一张打印记录表字段包括订单号、打印类型、打印时间、打印机编号、重试次数、打印结果。这张表同时服务于对账和排查「有订单但没打印记录」就是漏单「打印记录是失败但订单状态是已打单」就是状态更新异常。对账时以订单主表为准核钱以打印记录表为准核出餐两边对不上时再按订单号去翻明细。3. 用 Java 落地食堂订餐与打单表结构、下单接口、小票渲染这一章给出一套可以直接抄作业的最小实现。数据库用 MySQL 8Java 侧用 Spring Boot 单体应用打印机走 ESC/POS 协议。先建表再写下单接口最后写小票渲染这样能理解数据是怎么从用户请求一路走到打印机纸面上的。3.1 订餐核心表设计订单主表与明细表的关系订单主表的核心字段是订单号、状态、总金额和时间。订单号不要用数据库自增主键要用业务生成的有序编号原因有二一是自增 ID 会暴露食堂一天的真实单量二是打单、对账、客服查单时都要拿订单号做条件业务订单号比自增 ID 更像「单据凭证」。-- 订单主表 CREATE TABLE meal_order ( order_no VARCHAR(32) PRIMARY KEY COMMENT 业务订单号外部生成, user_id BIGINT NOT NULL COMMENT 用户ID, canteen_id INT NOT NULL COMMENT 食堂ID多食堂共用一套系统时用, business_date DATE NOT NULL COMMENT 业务日期日结切分使用, status TINYINT NOT NULL DEFAULT 0 COMMENT 0创建 1已支付 2已打单 3已完成, total_amount DECIMAL(8,2) NOT NULL COMMENT 订单总金额, pay_channel VARCHAR(16) DEFAULT NULL COMMENT 支付渠道, pickup_no VARCHAR(8) DEFAULT NULL COMMENT 取餐号, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_user_created (user_id, created_at), KEY idx_biz_date_status (business_date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE meal_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 订单号, dish_id BIGINT NOT NULL COMMENT 菜品ID, dish_name VARCHAR(64) NOT NULL COMMENT 菜名冗余防止菜谱改名后历史单据不可读, window_no INT NOT NULL COMMENT 出品窗口号打单分组依据, quantity INT NOT NULL DEFAULT 1 COMMENT 数量, price DECIMAL(8,2) NOT NULL COMMENT 下单时价格快照, KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这两张表要注意三个细节。第一明细表冗余了 dish_name 和 price因为食堂菜谱会改价、改菜名订单是历史凭证不允许跟着菜谱漂移。第二窗口编号 window_no 放在明细而不是主表因为一个订单可能横跨多个窗口主表放不下。第三业务日期 business_date 单独存不能用 created_at 替代否则日结切分时间一变历史数据全得改。3.2 下单接口事务、幂等与库存扣减下单接口是 Java 面试题里特别喜欢考的事务场景但实际写的时候有几个权衡点。我先说结论订单主表、明细表、扣库存必须在一个事务里小票打印不能在这个事务里。原因很直接——打印机是外部设备可能卡纸、断网、缺纸响应时间完全不可控把它放进数据库事务会导致连接池被占满。Service public class OrderService { Autowired private MealOrderMapper mealOrderMapper; Autowired private MealOrderItemMapper mealOrderItemMapper; Autowired private DishStockMapper dishStockMapper; Autowired private PrintQueue printQueue; Autowired private IdempotentCache idempotentCache; Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateCmd cmd, String userId) { // 幂等前端每次进入结算页生成一个 bizId重复点击不会重复下单 String idempotentKey createOrder: userId : cmd.getBizId(); String existed idempotentCache.get(idempotentKey); if (existed ! null) { return existed; } String orderNo OrderNoGenerator.next(); BigDecimal total calcTotal(cmd.getItems()); MealOrder order new MealOrder(); order.setOrderNo(orderNo); order.setUserId(Long.valueOf(userId)); order.setBusinessDate(BizDateUtil.currentBusinessDate()); order.setStatus(OrderStatus.CREATED.getValue()); order.setTotalAmount(total); order.setPickupNo(PickupNoGenerator.next(windowNo(cmd.getItems()))); mealOrderMapper.insert(order); for (OrderItemCmd item : cmd.getItems()) { // 乐观锁扣减当日可售份数stock quantity 才允许扣减 int rows dishStockMapper.deduct(item.getDishId(), item.getQuantity()); if (rows 0) { throw new BizException(菜品已售罄: item.getDishId()); } MealOrderItem orderItem new MealOrderItem(); orderItem.setOrderNo(orderNo); orderItem.setDishId(item.getDishId()); orderItem.setDishName(item.getDishName()); orderItem.setWindowNo(item.getWindowNo()); orderItem.setQuantity(item.getQuantity()); orderItem.setPrice(item.getPrice()); mealOrderItemMapper.insert(orderItem); } // 打印任务入队不直接调打印机 printQueue.push(new PrintTask(orderNo, PrintType.ORDER)); // 事务提交后写幂等标记如果幂等写进事务回滚时标记也要回滚 idempotentCache.set(idempotentKey, orderNo, Duration.ofMinutes(10)); return orderNo; } }下单接口有三个关键设计点。第一幂等控制在前端 bizId 加缓存用户双击、网络重试都不会生成第二单缓存失效时间设为 10 分钟超过后如果用户重新提交会生成一单新的符合食堂点餐预期。第二扣库存用的是乐观锁的写法对应 SQL 是UPDATE dish_stock SET stock stock - #{quantity} WHERE dish_id #{dishId} AND stock #{quantity}返回 0 就说明并发下有人抢走了最后一份。第三取餐号在接口里就生成后续补打小票时取餐号不变顾客不会因为补打换号。3.3 打单接口小票模板渲染与打印机对接小票渲染不要整 HTML 模板小票机只有 58mm 或 80mm 纸宽汉字一行最多十几个HTML 模式容易乱。常见做法是拼纯文本配合 ESC/POS 的简单指令控制字体放大和走纸。public class TicketRenderer { /** * 渲染一单一打的后厨出餐票 * 后厨不关心金额只关心菜品和数量 */ public String renderOrderTicket(OrderDTO order, ListOrderItemDTO items) { StringBuilder sb new StringBuilder(); sb.append(** 食堂订餐 **\n); sb.append(取餐号: ).append(order.getPickupNo()).append(\n); sb.append(订单号: ).append(order.getOrderNo()).append(\n); sb.append(时间: ).append(order.getCreatedAt()).append(\n); sb.append(----------------\n); for (OrderItemDTO item : items) { // 菜名最多截取12个汉字防止小票换行错位 String name item.getDishName(); if (name.length() 12) { name name.substring(0, 12); } sb.append(String.format(%-16s x%d\n, name, item.getQuantity())); } sb.append(----------------\n); return sb.toString(); } /** * 渲染窗口汇总单 * 用于后厨备餐字段和出餐票不同 */ public String renderSummaryTicket(ListOrderItemDTO groupedItems, int totalCount) { StringBuilder sb new StringBuilder(); sb.append(** 窗口备餐汇总 **\n); int sum 0; for (OrderItemDTO item : groupedItems) { sb.append(String.format(%-16s x%d\n, item.getDishName(), item.getQuantity())); sum item.getQuantity(); } sb.append(----------------\n); sb.append(合计份数: ).append(sum).append(\n); return sb.toString(); } }渲染逻辑里有个细节菜名超长要截断不截断的话 58mm 纸会强制折行折行后的第二行刚好落在切刀位置后厨扫一眼看不清是什么菜。汇总单和出餐票是两套模板出餐票带取餐号但不要金额汇总单不带订单号但带合计份数。这样设计是让后厨在高峰时段只看最少的有效信息。4. 打单落地的三种形态后台集中打印、自助终端、Web 直连打单模块不只是「调一下打印机 API」它与食堂的运营形态强相关。食堂规模小、窗口少一台电脑集中打单就够了食堂人流大、窗口分散需要自助终端让顾客自己取票如果食堂管理员想在多个办公室远程看单、补打小票就得考虑 Web 直连打印机。4.1 后台集中打单一台电脑管所有订单后台集中打单是最省事的方案适合窗口数不超过 6 个的小食堂。后台一台电脑连着一台或多台打印机每窗口一台或者一台共用Java 服务定时扫描状态为「已支付」的订单调打印机输出。核心是轮询间隔和失败处理。Component public class CentralPrintWorker { Autowired private MealOrderMapper orderMapper; Autowired private PrintService printService; // 每2秒扫一次间隔短会让打印机排队积压 Scheduled(fixedDelay 2000) public void pollPendingOrders() { ListOrderDTO pendingOrders orderMapper.findByStatus(OrderStatus.PAID.getValue()); if (pendingOrders.isEmpty()) { return; } for (OrderDTO order : pendingOrders) { boolean ok printService.tryPrint(order); if (ok) { orderMapper.updateStatus(order.getOrderNo(), OrderStatus.PRINTED.getValue()); } // 打印失败不置状态下一轮会自动重试 } } }这个写法的关键在于打印成功才更新订单状态失败就留在「已支付」池子里等下一轮。注意不要用Scheduled(fixedRate 2000)要用 fixedDelay因为 fixedRate 是固定频率如果某轮打印耗时超过 2 秒下一轮会立刻顶上造成重复打印fixedDelay 等上一轮结束再等 2 秒天然串行。4.2 自助终端打单排队叫号与重复打印控制自助终端是食堂订餐系统最常见的形态用户在手机或终端上完成点餐支付到食堂门口的终端机输入订单号或扫码取票。系统按窗口打印小票同时生成叫号序号。这里的核心问题不是打印而是重复打印。顾客容易手滑点两次「出票」或者取餐时发现小票丢了要求补打如果不加控制一张订单能打出一叠票。/** * 自助终端出票重点校验补打次数 */ public void printFromKiosk(String orderNo, Integer windowNo) { PrintRecord record printRecordMapper.findLatestByOrderNo(orderNo); if (record ! null record.getPrintCount() 3) { throw new BizException(该订单已补打3次请联系食堂管理员窗口); } if (record ! null LocalDateTime.now().minusSeconds(30).isBefore(record.getPrintTime())) { // 30秒内重复请求直接忽略防止连点 return; } PrintRecord newRecord new PrintRecord(); newRecord.setOrderNo(orderNo); newRecord.setPrintType(KIOSK); newRecord.setWindowNo(windowNo); newRecord.setPrintCount(record null ? 1 : record.getPrintCount() 1); printRecordMapper.insert(newRecord); OrderDTO order orderMapper.findByOrderNo(orderNo); printService.tryPrint(order); }自助终端的重复打印控制要同时做两层。第一层是 30 秒内的连点忽略防手抖第二层是累计补打上限防恶意刷票。补打上限设 3 次比较合理超过 3 次引导顾客去人工窗口因为超过 3 次的单大概率是订单本身有问题机器再打也是浪费纸。4.3 Web 端直连小票机协议选型与跨域问题不少源码会提供一个 Web 打印页面让管理员在浏览器里点「补打」。这里有个天然限制浏览器不能直接驱动本地小票机除非安装了浏览器插件或者用 WebSocket 转发。常见做法是写一个很小的 Java 本地助手程序部署在连着打印机的电脑上浏览器把补打印请求发给它再由它走 Socket 连接打印机。public class EscPosPrinterClient { /** * 连接局域网小票机 * 常见端口9100 是通用 RAW 打印端口部分打印机默认 9100 */ public void printToNetworkPrinter(String printerHost, String content) throws IOException { try (Socket socket new Socket(printerHost, 9100)) { socket.setSoTimeout(5000); OutputStream out socket.getOutputStream(); // 中文小票用 GBK 编码UTF-8 会让很多小票机打成一串乱码 out.write(content.getBytes(Charset.forName(GBK))); // ESC d n 走纸 n 行让最后一行完整露出切纸口 out.write(new byte[]{0x1B, 0x64, 0x03}); out.flush(); } } }Web 端直连要确认三件事打印机是否支持网络协议而不是只支持 USB电脑防火墙是否放开了对应端口小票机的本地字符集是否设置成了 GBK。很多翻车现场都是因为开发机用 UTF-8 调试正常部署到 Windows 打印机时中文全变问号实际是编码问题不是代码问题。5. 食堂订餐与打单系统避坑指南并发、打印丢单、退款竞态这一章写最常见的五个坑。每个坑都按「现象 → 原因 → 解决」来写这些不是玄学是真实食堂上线后会反复遇到的流程问题。5.1 午高峰并发下单导致订单号重复现象是午高峰时报主键冲突用户订单失败后台日志里堆着Duplicate entry xxx for key meal_order.PRIMARY。原因多数是源码里用时间戳 4位随机数生成订单号并发过百时随机数就会碰撞。解决方式是换全局有序 ID小规模用数据库号段大规模用雪花算法如果只是课程设计至少要在订单号上建唯一索引让冲突尽早暴露而不是等到对账时才发现少了单。下单接口里还要注意订单号生成器必须是线程安全的不能直接用SimpleDateFormat做并发格式化。5.2 小票机打印一半卡纸后厨漏出餐现象是后厨没收到票顾客等半天发现餐没做。原因多半是打印任务直接在同步调用里执行打印机缺纸或卡纸时抛异常异常被上层吞掉订单状态卡在「已支付」。解决方式是打单必须走独立任务队列失败要进重试重点看代码里有没有打印记录表。上线前用一个土办法验证拔掉打印机网线模拟下单 10 笔恢复网络后确认 10 笔全部补打出来不多不少。如果源码的打印逻辑是同步写在下单接口里的这一步就过不了。5.3 顾客退款后厨照样出餐现象是顾客在下单后立刻申请退款系统也把订单标记成已退款但后厨窗口还是把小票打了出来。原因是打印任务队列里积压的任务不会因为订单状态变更而撤销。解决方式有两个打单前二次校验订单状态发现已退款就不打印同时在后厨端给退款单一个醒目的「已退」标记。不要只在前端做拦截后端打单服务一定要自己查一遍状态打印是物理动作一旦纸出来了就很难收回。5.4 日结报表和支付平台账单对不上现象是每天凌晨对账系统金额和微信/支付宝账单差几块钱时间都集中在 23:50 到 00:10 之间。原因是两套系统的时间口径不一致订单表存本地时间支付平台回调带的是支付平台服务器时间跨天订单被归到不同日期。解决方式是引入业务日期字段由系统统一切分例如每天 05:00 到次日 04:59 算一个业务日所有日结报表、对账任务都以 business_date 为准不再拿 created_at 做日期分组。还要注意订单表里必须存支付回调的原始时间用于和渠道侧核对。5.5 已打印订单在报表里突然消失现象是后厨确实出了餐但日结报表里的出餐数比打印记录少。原因是打印记录表和订单状态表是分开更新的如果更新订单状态的语句失败打印记录表里有一条成功记录订单状态却还是「已支付」报表按订单状态统计自然漏掉。解决方式是对账以「打印记录表存在且打印结果为成功」为准同时让打印记录的写入与订单状态更新在同一个小事务里不过打印机操作不能进事务所以事务里只更新数据库字段不包含实际 socket 输出谁先谁后要设计好我习惯是先 print 成功再更新状态而不是先更新状态再 print。6. 从源码到上线优先补上状态机和打印重试如果你拿到的源码能跑通但你想让它真的在食堂里用起来我的建议是从两个地方动手一个是订单状态机一个是打印重试机制。这两块不涉及复杂业务却是整套系统最容易被写死的地方。6.1 用状态机把订单状态钉死很多源码对订单状态的处理是散落的if判断今天有人加一个「已完成」明天有人加一个「已退款」后天就出现「已退款也能打单」这种非法状态。用枚举状态机把允许流转的路径定义清楚是所有改动的基础。public enum OrderState { CREATED(0, 已创建), PAID(1, 已支付), PRINTED(2, 已打单), COMPLETED(3, 已完成), REFUNDED(4, 已退款), CANCELLED(5, 已取消); private final int value; private final String desc; private static final MapOrderState, SetOrderState ALLOWED new EnumMap(OrderState.class); static { ALLOWED.put(CREATED, EnumSet.of(PAID, CANCELLED)); ALLOWED.put(PAID, EnumSet.of(PRINTED, REFUNDED)); ALLOWED.put(PRINTED, EnumSet.of(COMPLETED, REFUNDED)); ALLOWED.put(COMPLETED, EnumSet.noneOf(OrderState.class)); ALLOWED.put(REFUNDED, EnumSet.noneOf(OrderState.class)); ALLOWED.put(CANCELLED, EnumSet.noneOf(OrderState.class)); } public void canTransitTo(OrderState target) { if (!ALLOWED.get(this).contains(target)) { throw new IllegalStateException(非法状态流转: this - target); } } }这个状态机允许「已支付」直接到「已退款」也允许「已打单」到「已退款」但「已退款」之后任何状态都不能再进。菜都出了才发现退款后厨人员需要线下把餐收回来这块风险不在状态机里而在线下流程里。上线前把状态机作为唯一的状态流转入口所有 Service 更新状态前先调用canTransitTo以后加需求就不会出现到处改 if 的局面。6.2 本地任务队列做打单重试重试逻辑最简单的做法是扫描打印记录表里失败的任务加上次数上限和退避时间。不要拿订单表重试因为订单表里没有打印次数和失败原因这些关键信息。Component public class PrintRetryWorker { private static final int MAX_RETRY 3; private static final Duration BACKOFF Duration.ofSeconds(30); Scheduled(fixedDelay 5000) public void retryFailedPrint() { ListPrintTask tasks printTaskMapper.findFailedTasks(MAX_RETRY, System.currentTimeMillis() - 30000); for (PrintTask task : tasks) { try { boolean ok printService.tryPrintByOrderNo(task.getOrderNo()); if (ok) { printTaskMapper.markSuccess(task.getId()); orderMapper.updateStatus(task.getOrderNo(), OrderStatus.PRINTED.getValue()); } else { printTaskMapper.increaseRetryCount(task.getId()); } } catch (Exception e) { printTaskMapper.increaseRetryCount(task.getId()); if (task.getRetryCount() MAX_RETRY) { printTaskMapper.markManual(task.getId()); } } } } }重试时要把超过 30 秒的失败任务捞出来再打避免刚失败立刻重试打印机都没来得及恢复多试几次只会让打印队列更堵。超过最大重试次数的任务进入「人工处理」状态并在后厨管理页面标红不要无限重试否则打印机恢复后会把半小时前的旧单全部打出来后厨反而更乱。6.3 上线前过一遍检查清单下面这张清单是我每次部署食堂订餐与打单系统之前都会做的验证你可以直接拿去做验收。检查项做法通过标准高峰并发用 JMeter 模拟 500 并发下单订单号无重复库存不为负数打印失败恢复拔掉打印机网线下 10 单恢复网络5 秒内自动补打无漏单退款竞态同时发起退款和打单请求状态机拦截非法流转跨天日结构造 23:55 下单、次日 00:10 回调的订单归属到前一个业务日补打限制同一订单连续补打 3 次第 4 次被拒绝并提示人工处理中文乱码打印机打一张含菜名和品牌的小票中文完整无问号这套系统的价值不在 CRUD而在把线下流程的边界处理清楚。源码能跑只是起点状态机和打印重试这两块不补上线后一定会在午高峰给你上一课。我的习惯是拿到任何一份订餐打单源码先看它有没有状态机、有没有打印记录表没有就先补这两个文件再谈其他功能。能够明确看见每一笔订单走到哪个环节、打印失败后系统怎么处理这套系统才算真正能交给食堂用希望帮到你。本文还有配套的精品资源点击获取
返回列表