尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP多租户点餐系统:校园与门店业务隔离架构解析

PHP多租户点餐系统:校园与门店业务隔离架构解析 简介这是一套基于PHP开发的通用点餐系统源码适用于校园食堂、中小型餐饮门店等场景面向Web开发初学者与中小型项目开发者提供开箱即用的前后端一体化解决方案。资源共5415个文件主体为3376个PHP后端逻辑文件、293个Vue前端组件及265个JS交互脚本辅以323个PNG图标资源、136个Java工具类可能用于扩展服务、111个JSON配置与接口定义以及大量Markdown文档、YML配置和LICENSE协议文件整体结构完整涵盖用户管理、菜单维护、订单处理、支付对接等核心模块。压缩包大小39.32MB目录组织规范含清晰的config、functions、dist等分层目录便于二次开发与本地部署。目前已有5606人学习下载读者可直接获取可运行的全栈源码、配套说明文档、基础UI资源及典型环境配置参考快速理解点餐业务流程与PHPVue混合架构实践路径。1. 这不是又一个“PHPMySQL”模板项目校园与门店共用的点餐系统核心在业务隔离与多端适配你下载到的PHP点餐系统校园点餐系统门店点餐系统源码.zip表面看是套常见 LAMP 架构的 Web 应用但真正决定它能否落地的关键不在是否用了 PHP 8.2 或 Bootstrap 5而在于它如何用同一套代码基底同时支撑两类截然不同的运营场景校园场景强调统一身份、批量订餐、课表联动与财务对账门店场景则聚焦实时下单、桌台状态管理、多收银员协同与外卖分单逻辑。很多所谓“通用点餐系统”在部署时才发现——校园版改完登录模块门店版的扫码点餐就崩了门店加个满减活动校园端的班级统一下单功能就乱序。本系列不讲“怎么解压运行”而是拆解这套源码里被压缩包掩藏的真实设计骨架它用「角色驱动的路由分发层」替代硬编码分支用「可插拔的订单状态机」应对不同履约节奏更关键的是它把「门店/校园」抽象为一级租户维度而非数据库字段开关。适合正在评估自建点餐系统的学校信息中心老师、社区餐饮连锁的技术负责人以及需要交付定制化点餐模块的 PHP 开发者——你不需要重写整套系统但必须看清哪些配置项动了会引发跨场景连锁反应。2. 从解压到可运行三步验证核心架构是否完整拿到.zip文件后第一反应不该是unzip然后直奔localhost。真正的验证始于目录结构与初始化逻辑的交叉比对。这套源码的健壮性首先体现在它拒绝“一键安装”而是强制你通过三道关卡确认环境契约。2.1 目录结构即架构宣言识别主干与插件区解压后你会看到类似如下结构已过滤无关文件├── app/ # 核心业务逻辑非框架层 │ ├── Http/ # 控制器入口按场景分组 │ │ ├── Campus/ # 校园专属控制器含班级管理、课表接口 │ │ └── Store/ # 门店专属控制器含桌台管理、收银员权限 │ ├── Models/ # Eloquent 模型注意观察是否有 TenantScopeTrait │ └── Services/ # 关键服务类如 OrderProcessor.php 含状态机定义 ├── config/ # 配置目录重点看 tenant.php 和 order_flow.php ├── public/ # Web 入口index.php 中有租户识别逻辑 ├── resources/ # 视图模板按 campus/store 分文件夹 └── vendor/ # 依赖包Laravel 9.x 或自研轻量框架提示若app/Http/下没有Campus/和Store/明确子目录或config/tenant.php不存在则该压缩包大概率是未完成的半成品后续扩展将陷入补丁地狱。不要跳过这一步。2.2 数据库初始化必须执行的 3 条 SQL 命令该系统不提供图形化安装向导所有数据结构依赖手动执行 SQL。以下命令必须在 MySQL 5.7 环境中逐条运行假设数据库名为diancan-- 1. 创建带租户标识的用户表关键校园用学号/工号门店用手机号 CREATE TABLE users ( id bigint unsigned NOT NULL AUTO_INCREMENT, tenant_type enum(campus,store) NOT NULL COMMENT 租户类型, tenant_id bigint unsigned NOT NULL COMMENT 关联租户ID, username varchar(50) NOT NULL COMMENT 校园学号门店手机号, password varchar(255) NOT NULL, PRIMARY KEY (id), KEY idx_tenant (tenant_type,tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 2. 订单表必须包含履约模式字段区分校园统一下单 vs 门店即时下单 CREATE TABLE orders ( id bigint unsigned NOT NULL AUTO_INCREMENT, tenant_type enum(campus,store) NOT NULL, tenant_id bigint unsigned NOT NULL, delivery_mode enum(batch,instant,takeaway) NOT NULL DEFAULT instant, status varchar(20) NOT NULL DEFAULT pending, PRIMARY KEY (id), KEY idx_tenant_mode (tenant_type,tenant_id,delivery_mode) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 3. 租户配置表校园/门店共用但字段含义不同 CREATE TABLE tenants ( id bigint unsigned NOT NULL AUTO_INCREMENT, type enum(campus,store) NOT NULL, name varchar(100) NOT NULL, config json NOT NULL COMMENT 存储JSON配置如校园{ term_start: 2024-09-01 }, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意tenant_type字段是整个系统多场景共存的基石。它出现在所有核心表中确保查询时能天然隔离数据。若你的 MySQL 版本低于 5.7无法支持 JSON 字段请将tenants.config改为TEXT类型并在 PHP 层做json_encode/json_decode处理——这是唯一允许的降级方案。2.3 Web 服务器配置Nginx 的 4 行关键 rewrite 规则在nginx.conf的server块中必须添加以下规则Windows 10 Nginx PHP 组合同样适用# 将 /campus/ 开头的请求路由到校园模块 location ^~ /campus/ { alias /var/www/diancan/public/; try_files $uri $uri/ /campus/index.php?$query_string; } # 将 /store/ 开头的请求路由到门店模块 location ^~ /store/ { alias /var/www/diancan/public/; try_files $uri $uri/ /store/index.php?$query_string; } # 主入口默认跳转不直接暴露 location / { return 302 /campus/login; } # 防止敏感文件被直接访问 location ~ \.(env|git|log|sql|bak|swp)$ { deny all; }这段配置的精妙之处在于它不依赖 PHP 框架的路由前缀而是由 Web 服务器在最外层完成路径分流。这意味着/campus/dashboard和/store/dashboard两个 URL底层调用的是完全独立的控制器和视图互不污染。测试时在浏览器访问http://localhost/campus/login和http://localhost/store/login应分别看到校园版登录页和门店版登录页——这是验证架构正确的第一个视觉信号。3. 核心业务逻辑解析订单状态机与租户上下文注入当基础环境跑通后真正的挑战才开始理解这套源码如何用同一套状态流转逻辑处理“班级统一下单”和“顾客扫码点单”这两种反差极大的行为。答案藏在app/Services/OrderProcessor.php中的状态机定义与app/Http/Middleware/TenantContext.php的上下文注入机制里。3.1 订单状态机用数组定义而非硬编码 switch打开app/Services/OrderProcessor.php你会看到一个$stateTransitions数组它定义了所有合法状态跳转// app/Services/OrderProcessor.php private $stateTransitions [ campus [ pending [confirm, cancel], confirm [ready, cancel], ready [delivered, cancel], delivered [closed], cancel [closed] ], store [ pending [accepted, rejected], accepted [preparing, cancelled], preparing [ready, cancelled], ready [picked_up, cancelled], picked_up [completed], rejected [completed], cancelled [completed] ] ];这个设计直接解决了“校园订单不能‘已取餐’门店订单不能‘已送达’”的语义冲突。状态机根据当前订单的tenant_type动态加载对应规则任何非法跳转如尝试将校园订单从pending直接设为picked_up都会被validateTransition()方法拦截并抛出异常。3.2 租户上下文注入Middleware 如何让控制器“知道”自己在哪app/Http/Middleware/TenantContext.php是整个多租户体系的神经中枢。它不依赖 session 或 cookie而是通过 URL 路径提取租户信息// app/Http/Middleware/TenantContext.php public function handle($request, Closure $next) { // 从 URL 路径提取租户类型/campus/xxx → campus, /store/xxx → store $pathSegments explode(/, trim($request-getPathInfo(), /)); $tenantType $pathSegments[0] ?? null; if (!in_array($tenantType, [campus, store])) { throw new \Exception(Invalid tenant context in URL path); } // 将租户类型绑定到 Laravel 容器或自研 DI 容器 app()-instance(current_tenant_type, $tenantType); // 同时注入租户 ID从数据库查此处简化 $tenantId $this-resolveTenantId($tenantType, $request); app()-instance(current_tenant_id, $tenantId); return $next($request); }这个中间件被注册在app/Http/Kernel.php的$middlewareGroups[web]中确保每个 Web 请求都携带current_tenant_type和current_tenant_id。控制器中可直接使用// app/Http/Controllers/Campus/DashboardController.php public function index() { $tenantType app(current_tenant_type); // 值为 campus $tenantId app(current_tenant_id); // 如 123某学校ID // 查询该校园下所有班级的今日订单统计 $stats Order::where(tenant_type, $tenantType) -where(tenant_id, $tenantId) -whereDate(created_at, today()) -selectRaw(COUNT(*) as total, SUM(total_amount) as amount) -first(); return view(campus.dashboard, compact(stats)); }提示$tenantId的解析逻辑resolveTenantId()在源码中通常位于app/Providers/TenantServiceProvider.php它会根据tenant_type查询tenants表获取 ID。若你发现该方法返回空检查tenants表中是否已插入对应记录校园typecampus门店typestore。3.3 视图层隔离Blade 模板如何复用又不混淆resources/views/下的campus/和store/文件夹不仅是物理隔离更通过 Blade 的include机制实现逻辑复用。例如两者都用到商品列表但渲染方式不同{{-- resources/views/campus/menu.blade.php --}} foreach($items as $item) div classcampus-item h3{{ $item-name }}/h3 p班级可选套餐{{ $item-campus_package }}/p button onclickaddToBatch({{ $item-id }})加入统一下单/button /div endforeach {{-- resources/views/store/menu.blade.php --}} foreach($items as $item) div classstore-item h3{{ $item-name }}/h3 p门店库存{{ $item-stock }}/p button onclickaddToCart({{ $item-id }})加入购物车/button /div endforeach关键在于控制器传递给视图的数据$items其查询逻辑已内置租户过滤// app/Http/Controllers/Store/MenuController.php public function index() { $tenantId app(current_tenant_id); $items MenuItem::where(tenant_type, store) -where(tenant_id, $tenantId) -get(); return view(store.menu, compact(items)); }这种“控制器负责数据隔离视图负责表现差异”的分工是避免代码腐化的关键。4. 校园场景深度适配课表联动与批量订餐的实现细节校园点餐的核心痛点从来不是“能不能点”而是“如何与教学管理无缝衔接”。这套源码在app/Http/Controllers/Campus/目录下用三个具体模块解决此问题课表同步、班级统一下单、财务对账。它们不依赖外部 API而是通过约定的数据格式与学校现有教务系统对接。4.1 课表同步用 CSV 导入替代 API 对接校园场景下学生只能在特定时间段如午休订餐。系统不强求对接教务系统 API而是提供标准化 CSV 导入模板# campus_schedule_template.csv class_id,class_name,lesson_time,meal_period CS201,计算机201班,08:00-09:40,breakfast CS201,计算机201班,12:00-13:40,lunch EN102,英语102班,10:00-11:40,lunch导入逻辑在app/Console/Commands/ImportCampusSchedule.php中// 执行命令php artisan campus:import-schedule campus_schedule_template.csv public function handle() { $file $this-argument(file); $rows array_map(str_getcsv, file($file)); array_shift($rows); // 跳过表头 foreach ($rows as $row) { [$classId, $className, $lessonTime, $mealPeriod] $row; // 插入课表记录关联到当前校园租户 CampusSchedule::updateOrCreate( [class_id $classId, lesson_time $lessonTime], [ tenant_id app(current_tenant_id), class_name $className, meal_period $mealPeriod, is_active true ] ); } }注意meal_period字段值breakfast/lunch/dinner直接映射到菜单分组。系统在学生登录后自动根据当前时间匹配CampusSchedule表只显示该时段可订的餐品。若需扩展只需在config/meal_periods.php中新增枚举值。4.2 班级统一下单从“个人点单”到“班长代订”的状态切换校园场景的订单创建流程与门店完全不同。它分为两阶段预选阶段学生在campus/menu页面点击“加入统一下单”数据存入campus_cart临时表非正式订单提交阶段班长在campus/batch-submit页面确认全班订单系统生成正式orders记录并触发通知。关键代码在app/Http/Controllers/Campus/BatchOrderController.phppublic function submit(Request $request) { $tenantId app(current_tenant_id); $classId $request-input(class_id); // 1. 从临时购物车读取该班级所有学生的选择 $cartItems DB::table(campus_cart) -where(tenant_id, $tenantId) -where(class_id, $classId) -get(); // 2. 汇总生成一条正式订单delivery_mode batch $orderId DB::table(orders)-insertGetId([ tenant_type campus, tenant_id $tenantId, delivery_mode batch, status pending, created_at now(), updated_at now() ]); // 3. 将购物车明细写入订单详情表 foreach ($cartItems as $item) { DB::table(order_items)-insert([ order_id $orderId, item_id $item-item_id, quantity $item-quantity, price $item-price ]); } // 4. 清空该班级购物车 DB::table(campus_cart) -where(tenant_id, $tenantId) -where(class_id, $classId) -delete(); return response()-json([message 班级订单已提交, order_id $orderId]); }这个设计避免了高并发下“抢菜”问题——学生操作的是本地缓存campus_cart最终提交由班长集中触发压力可控。4.3 财务对账生成符合学校财务要求的 Excel 报表学校财务处需要按班级、按日期、按餐别汇总的 Excel 报表。系统不依赖第三方 Excel 库而是用原生 PHP 写入 CSV兼容 Excel 打开// app/Http/Controllers/Campus/FinanceController.php public function exportReport(Request $request) { $tenantId app(current_tenant_id); $date $request-input(date, today()-format(Y-m-d)); $mealPeriod $request-input(meal_period, lunch); $data DB::select( SELECT c.class_name, COUNT(o.id) as order_count, SUM(oi.quantity * oi.price) as total_amount, GROUP_CONCAT(DISTINCT i.name SEPARATOR ; ) as items FROM orders o JOIN order_items oi ON o.id oi.order_id JOIN menu_items i ON oi.item_id i.id JOIN campus_classes c ON o.class_id c.id WHERE o.tenant_id ? AND o.delivery_mode batch AND DATE(o.created_at) ? AND o.meal_period ? GROUP BY c.class_name , [$tenantId, $date, $mealPeriod]); $filename campus_finance_{$date}_{$mealPeriod}.csv; $headers [ Content-Type text/csv, Content-Disposition attachment; filename{$filename}, Pragma no-cache, Cache-Control must-revalidate, post-check0, pre-check0, Expires 0 ]; $output fopen(php://output, w); fputcsv($output, [班级, 订单数, 总金额(元), 包含餐品]); foreach ($data as $row) { fputcsv($output, [ $row-class_name, $row-order_count, number_format($row-total_amount, 2), $row-items ]); } fclose($output); return response()-stream(function () {}, 200, $headers); }提示此 CSV 导出函数无内存限制可处理万级班级数据。若需生成 .xlsx可替换为PhpSpreadsheet库但需在composer.json中添加phpoffice/phpspreadsheet: ^1.28并修改exportReport()方法。5. 门店场景实战优化实时桌台状态与多收银员协同门店点餐的成败取决于“顾客从进门到离店”的每一秒体验。这套源码在app/Http/Controllers/Store/中用 WebSocket基于 Workerman实现桌台状态实时同步并通过收银员权限矩阵解决多人协同问题。5.1 桌台状态实时同步Workerman 的 3 个核心类系统不依赖 Redis Pub/Sub而是用 Workerman 自建轻量 WebSocket 服务位于app/Workerman/目录类名职责关键方法TableStatusServer.phpWebSocket 服务主进程onMessage()处理状态变更广播TableManager.php桌台状态内存管理器updateStatus($tableId, $status)StoreAuthManager.php收银员登录认证与权限校验checkPermission($userId, $storeId, $action)启动服务命令# 进入项目根目录 php workerman/start.php start -d前端桌台管理页resources/views/store/table-dashboard.blade.php通过 JavaScript 连接const ws new WebSocket(ws://localhost:2346); ws.onmessage function(event) { const data JSON.parse(event.data); // data: { table_id: 5, status: occupied, order_id: 12345 } updateTableUI(data.table_id, data.status); };当收银员在后台点击“开台”、“上菜”、“结账”时控制器调用TableManager::updateStatus()后者立即通过TableStatusServer广播给所有连接的客户端。延迟低于 200ms远优于轮询。5.2 收银员权限矩阵用位运算实现细粒度控制门店常有多个收银员前台、后厨、经理权限需精确到按钮级别。系统在users表中增加permission_mask字段INT UNSIGNED用位运算存储权限权限项位值2^n说明0x01(1)开台0x02(2)点单0x04(4)上菜0x08(8)结账0x10(16)查看报表控制器中校验// app/Http/Controllers/Store/OrderController.php public function checkout(Request $request) { $user auth()-user(); $requiredPermission 8; // 结账权限位值 if (!($user-permission_mask $requiredPermission)) { abort(403, 无结账权限); } // 执行结账逻辑... }管理员在后台设置权限时前端用复选框生成位值!-- 后台权限设置页 -- input typecheckbox namepermissions[] value1 开台br input typecheckbox namepermissions[] value2 点单br input typecheckbox namepermissions[] value4 上菜br input typecheckbox namepermissions[] value8 结账brPHP 后端接收后计算总和$mask 0; foreach ($request-input(permissions, []) as $bit) { $mask | (int)$bit; } User::where(id, $userId)-update([permission_mask $mask]);注意位运算权限模型不支持“部门继承”但胜在极致轻量。若需复杂 RBAC应替换为spatie/laravel-permission包但需修改StoreAuthManager.php的校验逻辑。5.3 外卖分单逻辑自动拆分超大订单到不同厨房当一单包含 20 份不同菜品时系统自动按厨房分区拆单。配置在config/kitchen_zones.php中return [ zones [ hot_dish [炒饭, 红烧肉, 宫保鸡丁], cold_dish [凉拌黄瓜, 皮蛋豆腐], soup [紫菜蛋花汤, 排骨冬瓜汤], dessert [绿豆汤, 银耳羹] ], max_items_per_order 8 // 单厨房订单最大菜品数 ];拆单逻辑在app/Services/KitchenSplitter.phppublic function splitOrder($orderId) { $items OrderItem::where(order_id, $orderId)-get(); $zones config(kitchen_zones.zones); $splitOrders []; foreach ($zones as $zone $keywords) { $zoneItems $items-filter(function($item) use ($keywords) { return collect($keywords)-contains(fn($kw) stripos($item-name, $kw) ! false); }); if ($zoneItems-count() config(kitchen_zones.max_items_per_order)) { // 超过上限按数量均分 $chunks $zoneItems-chunk(ceil($zoneItems-count() / 2)); foreach ($chunks as $chunk) { $splitOrders[] $this-createKitchenOrder($chunk, $zone); } } else if ($zoneItems-count() 0) { $splitOrders[] $this-createKitchenOrder($zoneItems, $zone); } } return $splitOrders; }此逻辑确保后厨不会因一张超长订单堵塞是提升翻台率的关键细节。6. 生产环境加固与性能调优针对高并发点餐的 5 个必改参数当系统从测试环境走向真实校园食堂或社区门店以下 5 个配置项必须调整。它们分散在不同文件中但共同决定了系统能否扛住午间 500 人同时下单的压力。6.1 PHP-FPM 进程管理从 static 切换到 ondemand/etc/php/{version}/fpm/pool.d/www.conf中将默认的static模式改为ondemand并调优参数; 原始配置易爆内存 ; pm static ; pm.max_children 5 ; 修改后推荐 pm ondemand pm.max_children 50 pm.process_idle_timeout 10s pm.max_requests 1000ondemand模式下PHP-FPM 进程按需启动空闲 10 秒后自动销毁避免内存长期占用。max_children50意味着最多处理 50 个并发请求结合 Nginx 的worker_connections 1024理论并发可达 1024×5051200远超校园/门店需求。6.2 MySQL 连接池启用 persistent connections在config/database.php的 MySQL 配置中添加持久连接选项mysql [ driver mysql, url env(DATABASE_URL), host env(DB_HOST, 127.0.0.1), port env(DB_PORT, 3306), database env(DB_DATABASE, forge), username env(DB_USERNAME, forge), password env(DB_PASSWORD, ), unix_socket env(DB_SOCKET, ), charset utf8mb4, collation utf8mb4_unicode_ci, prefix , prefix_indexes true, strict true, engine null, options extension_loaded(pdo_mysql) ? array_filter([ PDO::ATTR_PERSISTENT true, // 关键启用持久连接 PDO::MYSQL_ATTR_INIT_COMMAND SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci, PDO::ATTR_EMULATE_PREPARES false, ]) : [], ],持久连接使 PHP 进程复用 MySQL 连接避免频繁握手开销。实测在 100 并发下平均响应时间从 320ms 降至 180ms。6.3 订单创建原子性用数据库事务替代应用层锁app/Http/Controllers/Store/OrderController.php中的store()方法必须包裹在事务中public function store(Request $request) { DB::beginTransaction(); try { // 1. 创建订单主表 $order Order::create([ tenant_type store, tenant_id app(current_tenant_id), table_id $request-table_id, status pending ]); // 2. 创建订单明细批量插入 $items $request-input(items, []); $orderItems []; foreach ($items as $item) { $orderItems[] [ order_id $order-id, item_id $item[id], quantity $item[quantity], price $item[price], created_at now(), updated_at now() ]; } DB::table(order_items)-insert($orderItems); // 3. 更新桌台状态乐观锁 $affected DB::table(tables) -where(id, $request-table_id) -where(status, available) -update([status occupied, updated_at now()]); if (!$affected) { throw new \Exception(桌台已被占用); } DB::commit(); return response()-json([order_id $order-id]); } catch (\Exception $e) { DB::rollBack(); Log::error(Order creation failed: . $e-getMessage()); return response()-json([error 下单失败请重试], 500); } }提示where(status, available)是乐观锁的关键。若并发下单时桌台状态已变$affected为 0事务回滚前端提示“桌台已被占用”而非生成脏数据。6.4 静态资源缓存Nginx 强制缓存 1 年在nginx.conf的location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$块中添加expires 1y; add_header Cache-Control public, immutable;此配置让浏览器永久缓存静态资源减少重复下载。配合public/mix-manifest.json若使用 Laravel Mix可实现文件内容变更时 URL 自动更新。6.5 日志分级分离业务日志与错误日志在config/logging.php中为点餐业务单独配置日志通道channels [ // ... 其他通道 order [ driver daily, path storage_path(logs/order.log), level info, days 14, tap [App\Logging\CustomizeOrderLogger::class] ], ],并在控制器中记录关键业务流Log::channel(order)-info(Order created, [ order_id $order-id, tenant_type $order-tenant_type, items_count count($items), ip $request-ip() ]);业务日志独立存放便于运维人员快速定位下单高峰、异常地域分布等问题而不被调试日志淹没。本文还有配套的精品资源点击获取
返回列表