PHP食堂预约订餐系统实战:从餐次容量到取餐码核销的完整实现
简介这份资源是面向高校计算机相关专业学生与PHP初学者的一份毕业设计文档主题为基于PHP的食堂预约订餐系统设计与实现适合作为课程设计、毕业设计选题参考或Web开发入门练手项目。压缩包内仅含1个docx文档大小约3.03MB内容围绕系统开发环境、Web服务器选型、B/S架构、数据库设计、功能模块实现及应用前景等展开涵盖用户注册登录、预约订餐、订单管理、菜单管理等核心业务并配有系统功能结构图、流程图与数据库表设计说明。目前已有206人学习下载读者可从中获取完整的选题思路、需求分析与数据库设计方法以及管理员模块与用户模块的实现要点便于快速搭建同类订餐系统的开发框架也可作为论文写作与答辩准备的参考素材。1. 食堂预约订餐系统为什么“能下单”不等于“能开饭”很多开发者第一次做食堂预约订餐系统都会把注意力放在“下单成功”这个动作上用户点一份番茄炒蛋库存减一订单入库页面跳转收工。但真把系统放到一个每天中午要承接上千人次的食堂场景里最先崩的往往不是下单接口而是“预约”这两个字背后的时间窗、容量和履约逻辑。基于 PHP 的食堂预约订餐系统本质是一套把“未来某一餐的供给能力”提前分配给“现在想吃饭的人”的排程工具它要解决的是高峰期排队、备餐量拍脑袋、取餐找不到订单这三件事。适合谁做中小型食堂、园区餐厅、高校后勤这类有固定就餐人群、菜单相对稳定、但缺少定制化排餐工具的场景。PHP 在这里不是妥协而是因为它的部署门槛低、和常见 Web 服务器配合成熟改起来快适合先跑通再迭代。2. 先定模型再写代码预约、餐次与容量的三张表2.1 为什么不能把“菜品库存”直接当“餐次容量”新手最容易犯的错是给每道菜设一个库存字段用户下单就减一卖完为止。这在点餐场景里没问题但在预约场景里会翻车食堂的产能瓶颈通常不是某一道菜而是整个餐次的总出餐能力。比如一个窗口 11:30 到 12:30 最多出 300 份你就算有 500 份宫保鸡丁的原料也只能出 300 份。所以模型要分两层餐次meal_slot管总容量和时间窗菜品dish管可选范围和单菜上限。用户预约时先占餐次容量再选菜品两个校验都要过。常见做法是建三张核心表meal_slot记录日期、餐次类型、起止时间、总容量、已预约数dish记录菜品、单价、单餐次限量和所属餐次order记录用户、餐次、菜品明细、取餐码和状态。下面是我一般会用的建表语句字段名保持直白方便后面排查。-- 餐次表一个日期一个餐次类型就是一条记录 CREATE TABLE meal_slot ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, slot_date DATE NOT NULL COMMENT 供餐日期, slot_type TINYINT NOT NULL COMMENT 1早餐 2午餐 3晚餐, start_time TIME NOT NULL, end_time TIME NOT NULL, total_capacity INT NOT NULL DEFAULT 0 COMMENT 该餐次总出餐份数, booked_count INT NOT NULL DEFAULT 0 COMMENT 已预约份数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0关闭, UNIQUE KEY uk_date_type (slot_date, slot_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 菜品表单菜限量是防止某个爆款被一个人全包 CREATE TABLE dish ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, slot_id INT UNSIGNED NOT NULL, dish_name VARCHAR(64) NOT NULL, price DECIMAL(8,2) NOT NULL DEFAULT 0, per_limit INT NOT NULL DEFAULT 1 COMMENT 单笔订单最多点几份, daily_limit INT NOT NULL DEFAULT 0 COMMENT 0表示不限, sold_count INT NOT NULL DEFAULT 0, KEY idx_slot (slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表取餐码要唯一状态机要简单 CREATE TABLE order ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, slot_id INT UNSIGNED NOT NULL, pick_code CHAR(6) NOT NULL COMMENT 6位取餐码, total_amount DECIMAL(8,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待取 1已取 2已取消, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_pick_code (pick_code), KEY idx_user_slot (user_id, slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明meal_slot上的UNIQUE KEY保证同一天同一餐次只有一条记录避免后台重复生成。booked_count是冗余字段用它是为了在扣减时用一条UPDATE ... WHERE booked_count total_capacity完成原子占位而不是先查再改。参数上total_capacity建议按窗口实际出餐能力填不要按历史最大订单量填否则超卖是迟早的事。per_limit和daily_limit分开是因为“一个人一单最多点两份”和“这道菜今天总共只做 50 份”是两回事。2.2 预约占位的原子扣减怎么写容量扣减是整个系统最容易出并发问题的地方。两个用户同时看到还剩 1 份同时点确认如果代码是“先 SELECT 再 UPDATE”就会双双成功超卖一份。正确做法是把判断条件写进 UPDATE 的 WHERE 里靠数据库行锁保证原子性。?php // $pdo 为已建立的 PDO 连接开启异常模式 $pdo-beginTransaction(); try { // 原子占位只有已预约数小于总容量时才更新成功 $stmt $pdo-prepare( UPDATE meal_slot SET booked_count booked_count 1 WHERE id :slot_id AND status 1 AND booked_count total_capacity ); $stmt-execute([:slot_id $slotId]); // rowCount 为 0 说明容量已满或餐次已关闭 if ($stmt-rowCount() 0) { throw new RuntimeException(该餐次已约满或已关闭); } // 再校验菜品单菜上限同样用条件更新 $dishStmt $pdo-prepare( UPDATE dish SET sold_count sold_count :qty WHERE id :dish_id AND (daily_limit 0 OR sold_count :qty daily_limit) ); $dishStmt-execute([:qty $qty, :dish_id $dishId]); if ($dishStmt-rowCount() 0) { throw new RuntimeException(该菜品已售完); } // 写入订单取餐码由随机数生成后查重 $pickCode str_pad((string)random_int(0, 999999), 6, 0, STR_PAD_LEFT); $ins $pdo-prepare( INSERT INTO order (user_id, slot_id, pick_code, total_amount, status) VALUES (:uid, :sid, :code, :amount, 0) ); $ins-execute([ :uid $userId, :sid $slotId, :code $pickCode, :amount $amount, ]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); // 这里返回具体原因前端据此提示 echo json_encode([ok false, msg $e-getMessage()]); }逻辑说明整个流程放在一个事务里任何一步失败都回滚避免“餐次扣了但菜品没扣”的脏数据。参数上booked_count total_capacity这个条件必须和booked_count 1在同一个 UPDATE 里不能拆开。取餐码用 6 位数字是因为人工核对时短、好念但要注意查重uk_pick_code唯一索引会在冲突时抛异常捕获后重试即可。这里没有用SELECT ... FOR UPDATE是因为条件更新更轻锁的范围更小食堂这种量级完全够用。3. 从选餐到取餐一条订单的完整状态流转3.1 下单接口要校验的四件事下单接口看起来只是收参数、写库但真正决定系统稳不稳的是校验顺序。我一般按“餐次是否开放 → 是否在预约时间窗内 → 容量是否够 → 菜品是否属于该餐次”这个顺序来任何一步不过就直接返回不往下走。顺序不能乱因为容量扣减是有副作用的先扣了再发现菜品不对回滚虽然能救但白白增加锁竞争。?php // 校验餐次是否在可预约时间窗内 $slot $slotRepo-find($slotId); if (!$slot || $slot[status] ! 1) { exit(json_encode([ok false, msg 餐次未开放])); } $now date(H:i:s); // 常见做法是提前截止比如开餐前30分钟停止预约 $deadline date(H:i:s, strtotime($slot[start_time]) - 1800); if ($now $deadline) { exit(json_encode([ok false, msg 已过预约截止时间])); } // 菜品必须属于该餐次防止跨餐次点单 $dish $dishRepo-find($dishId); if (!$dish || $dish[slot_id] ! $slotId) { exit(json_encode([ok false, msg 菜品与餐次不匹配])); }逻辑说明$deadline用开餐时间减 1800 秒也就是提前 30 分钟截止这个值要按食堂实际备餐节奏调备餐慢的可以提前到 60 分钟。参数上start_time存的是TIME类型和$now比较时要注意时区一致PHP 的date_default_timezone_set要和数据库会话时区对齐否则会出现“明明没到点却提示已截止”的玄学问题。3.2 取餐码核销与状态机取餐环节的核心是“一码一单核销即改状态”。食堂窗口的网络往往不如办公室稳定所以核销接口要做得尽量短最好一次请求完成“查订单 → 校验状态 → 改状态 → 返回结果”。状态只保留待取、已取、已取消三种不要加“制作中”“待支付”这类中间态除非你真有对应的操作环节否则状态越多对不上的概率越大。?php // 核销根据取餐码把待取订单改为已取 $stmt $pdo-prepare( UPDATE order SET status 1 WHERE pick_code :code AND status 0 ); $stmt-execute([:code $pickCode]); if ($stmt-rowCount() 0) { // 可能是码不存在也可能是已经取过了 echo json_encode([ok false, msg 取餐码无效或已核销]); } else { echo json_encode([ok true, msg 核销成功]); }逻辑说明status 0这个条件保证同一张订单不会被核销两次即使窗口人员手快连点两下第二次也会因为rowCount为 0 而返回失败。参数上取餐码建议只允许数字输入时用数字键盘减少误触。如果食堂有多个窗口可以在订单表加一个window_id核销时限定窗口避免 A 窗口的码跑到 B 窗口取。4. 避坑与排查那些让预约系统“看起来能用”的细节4.1 坑一时区不一致导致预约时间窗错乱现象后台设置午餐 11:30 开始用户 11:00 打开页面却提示“已过预约截止时间”或者反过来明明过了点还能下单。原因通常是 PHP 时区和 MySQL 会话时区不一致date(H:i:s)取的是 PHP 时区而start_time存进去时按另一个时区解释。解决在数据库连接建立后立刻执行SET time_zone 08:00同时在 PHP 入口用date_default_timezone_set(Asia/Shanghai)两边对齐。排查时直接SELECT NOW()和echo date(Y-m-d H:i:s)对比差几个小时一眼就能看出来。4.2 坑二容量扣了但订单没写进去现象餐次显示已约满但订单列表里找不到对应记录。原因多半是事务没包全或者commit之前抛了异常但没回滚。解决把容量扣减、菜品扣减、订单写入放在同一个try里catch里必须rollBack。另外注意 PDO 的ERRMODE_EXCEPTION要打开否则出错不抛异常事务会静默失败。排查时看meal_slot.booked_count和order表按slot_id分组计数是否一致不一致就是这里出了问题。4.3 坑三取餐码重复导致核销错单现象两个用户拿到同一个取餐码窗口核销时把别人的单取走了。原因是用rand()生成且没做唯一约束或者做了约束但没处理冲突异常。解决用random_int生成并在pick_code上加唯一索引插入冲突时捕获异常重新生成重试三次还冲突就返回系统繁忙。排查时直接SELECT pick_code, COUNT(*) FROM order GROUP BY pick_code HAVING COUNT(*) 1有结果就说明约束没生效。4.4 坑四取消订单后容量没还回去现象用户取消预约但餐次还是显示已满别人约不上。原因取消逻辑只改了订单状态忘了把booked_count减一。解决取消操作也要放在事务里先UPDATE order SET status 2 WHERE id ? AND status 0确认rowCount为 1 后再UPDATE meal_slot SET booked_count booked_count - 1 WHERE id ? AND booked_count 0。注意减的时候要加booked_count 0防止减成负数。排查时对比订单状态为已取消的数量和booked_count的差值对不上就是漏还了。4.5 坑五高峰期数据库连接被打满现象中午 11:30 前后页面转圈日志里大量连接超时。原因每个请求都新建 PDO 连接或者长事务把连接占住。解决用单例或连接池复用 PDO事务里只做必要的写操作不要在事务里调外部接口或做耗时计算。参数上MySQL 的max_connections按并发量调PHP-FPM 的pm.max_children也要和它匹配否则不是数据库先崩就是 PHP 进程先排队。排查时看SHOW PROCESSLIST里有多少连接处于Sleep或LockedSleep 太多说明连接没复用。5. 让系统真正省人力的两个进阶技巧第一个技巧是把“预约截止”和“备餐汇总”做成定时任务。食堂最需要的不是用户下单那一刻的数据而是开餐前 40 分钟的一份汇总每个菜品要做多少份、每个餐次总共多少单。用 PHP 写一个 CLI 脚本配合系统的计划任务在截止时间后跑一次把order按dish_id分组统计输出到一张prep_summary表后厨直接看这张表备餐。这样比让后厨去翻订单列表快得多也避免了“看漏了”这种血泪经验。?php // 备餐汇总脚本建议在预约截止后由计划任务触发 $slotId (int)$argv[1]; $sql SELECT d.dish_name, COUNT(oi.id) AS qty FROM order_item oi JOIN order o ON o.id oi.order_id JOIN dish d ON d.id oi.dish_id WHERE o.slot_id :sid AND o.status 0 GROUP BY d.id, d.dish_name; $stmt $pdo-prepare($sql); $stmt-execute([:sid $slotId]); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); // 写入汇总表后厨按这个数量备餐 $ins $pdo-prepare( INSERT INTO prep_summary (slot_id, dish_name, qty) VALUES (:sid, :name, :qty) ON DUPLICATE KEY UPDATE qty VALUES(qty) ); foreach ($rows as $r) { $ins-execute([:sid $slotId, :name $r[dish_name], :qty $r[qty]]); } echo 汇总完成共 . count($rows) . 个菜品\n;逻辑说明ON DUPLICATE KEY UPDATE保证脚本重复跑不会产生重复行前提是prep_summary上有(slot_id, dish_name)的唯一索引。参数上$argv[1]是餐次 ID由计划任务传入这样同一个脚本可以给不同餐次复用。注意这个脚本只统计status 0的待取订单已取消的不算已取的一般是现场取的也不影响备餐。第二个技巧是给取餐码加一个“短时有效”的展示页。用户到窗口前打开页面页面用 AJAX 每 10 秒刷新一次订单状态核销后自动变成“已取餐”避免用户举着手机等窗口人员确认。这个页面不需要登录态用取餐码加一个签名参数就能访问签名用hash_hmac生成防止别人遍历取餐码。参数上签名有效期设 2 小时过期后要求重新登录查看既方便又不会长期暴露。我自己做这类系统最大的教训是总想先把功能做全再上线结果拖了很久后厨还是靠纸笔。后来改成先跑通“预约 取餐码 备餐汇总”这条最短路径上线一周后再加取消、改单、多窗口这些反而推得动。食堂场景里能少让一个人排队、少让后厨估错一次量这个系统就值了。希望帮到你。本文还有配套的精品资源点击获取