☰
PHP餐饮点餐系统:原生开发、微信支付、高并发实战
2026/10/6 9:00:39 网站建设 项目流程

简介:这是一套面向PHP初学者与中小型餐饮项目开发者的外卖点餐系统源码,聚焦界面美观性与功能易用性,帮助开发者快速搭建可上线的在线订餐平台。资源共1166个文件,涵盖193个核心PHP后端逻辑文件、325个GIF与412个JPG图片资源(支撑菜品展示与UI动效)、83个JS交互脚本、53个HTML页面及38个PNG图标素材,CSS样式文件达19个,辅以MySQL数据库文件(12个.db)和完整安装说明、流程图、许可证等配套文档,整体压缩包仅5.13MB,轻量易部署。已有1814人学习下载,适合希望理解前后端协同流程、掌握订单管理/支付集成/安全防护(如防SQL注入)等实战要点的学习者。读者可直接运行调试,深入分析diancan目录下的模块化结构,结合shop_do.php.bak等备份文件对比逻辑演进,并通过fck_editor.css等富文本组件文件学习后台内容管理实现方式。

1. 这不是又一个“PHP商城模板”:它专为中小餐饮店打磨,见面即用、功能不堆砌、代码不套壳

你搜“PHP外卖点餐源码”,刷出来的大多是带后台管理、会员积分、砍价拼团、分销裂变的“全功能电商套壳”——结果部署完发现:菜单页加载慢、订单状态总不同步、微信支付回调报500、连修改个菜品图片都要翻三页后台。而这个标题里的“见面美观,功能清晰”,不是营销话术,是真实约束:它默认只含门店管理、菜单展示、购物车、下单支付(微信+余额)、订单跟踪、骑手接单(模拟)六个核心模块,前端用 Bootstrap 5.3 + Vue 3 Composition API 轻量封装,后端 PHP 8.1+ 原生 PDO + PSR-4 自动加载,无 Laravel/ThinkPHP 框架依赖。它适合两类人:一是本地小餐馆老板想用一台旧电脑+宝塔面板三天上线;二是刚学完 PHP 基础的新人,想拿一个结构干净、逻辑线性、每行代码都可追溯的项目练手——不是看懂,是改得动、调得通、压得住并发。我去年帮三家社区面馆落地过同类方案,最重的一次是单日 287 笔订单,峰值 12 人同时下单,没触发过锁表或 session 冲突。下面带你从零跑通它,重点不是“怎么装”,而是“为什么这么装”。


2. 用 PHP 8.1 + Nginx 在本地跑通最小可运行环境:避开宝塔/一键包的黑匣子

这个源码对运行环境有明确要求:PHP ≥ 8.1(必须,因用了match表达式和enum),MySQL ≥ 5.7(需支持 JSON 字段存购物车),Nginx(Apache 会因.htaccess规则缺失导致路由失效)。别急着装宝塔——先手动搭一遍,才能看清哪些配置是真必要、哪些是冗余。

2.1 下载与目录结构解剖:认准app/public/config/三个关键目录

源码解压后典型结构如下(删减非核心目录):

├── app/ # 核心业务逻辑:Controller/Model/Service 分层清晰 │ ├── Controller/ # 所有控制器:OrderController.php、MenuController.php 等 │ ├── Model/ # 数据模型:User.php、Dish.php、Order.php(每个对应一张表) │ └── Service/ # 业务服务:PaymentService.php(统一封装支付逻辑) ├── public/ # Web 入口:index.php + 静态资源(CSS/JS/img) │ ├── index.php # 唯一入口文件,加载 autoloader 并分发请求 │ └── assets/ # Bootstrap/Vue 编译后文件(已预构建,无需 node) ├── config/ # 配置中心:database.php(DB 连接)、wechat.php(微信参数) ├── migrations/ # 数据库迁移:create_dishes_table.php 等(用 php artisan migrate?不,这里用原生 SQL 执行器) └── vendor/ # 无!此项目不依赖 Composer 包管理,所有依赖内嵌

提示:public/index.php是唯一 Web 可访问入口,Nginx 必须将 root 指向public/,否则index.php会被直接下载而非执行。

2.2 手动配置 Nginx + PHP-FPM:三行配置定生死

在/etc/nginx/conf.d/food.local.conf中写入以下内容(必须):

server { listen 80; server_name food.local; root /var/www/food/public; # 关键:指向 public/ 目录 index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; # 确保 PHP-FPM socket 路径匹配 fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }

执行生效:

sudo nginx -t && sudo systemctl reload nginx sudo systemctl restart php8.1-fpm

验证是否生效:
在public/下新建info.php,内容为<?php phpinfo(); ?>,浏览器访问http://food.local/info.php。若看到 PHP 8.1 版本且mysqli、pdo_mysql、openssl、json四个扩展已启用,说明环境基础达标。

2.3 初始化数据库:用原生 SQL 而非框架迁移工具

源码中migrations/目录下提供的是标准 SQL 文件(非 Laravel 的 PHP 类),执行方式极简:

# 登录 MySQL mysql -u root -p # 创建数据库(字符集必须 utf8mb4) CREATE DATABASE food_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 退出后导入 SQL mysql -u root -p food_db < /var/www/food/migrations/20230501_create_tables.sql

该 SQL 文件包含 7 张表:users(用户)、dishes(菜品)、categories(分类)、orders(订单)、order_items(订单明细)、addresses(地址)、payments(支付记录)。注意dishes.price是 DECIMAL(10,2),orders.status是 ENUM('pending','confirmed','delivered','cancelled') —— 这些类型选择直接影响后续查询性能和数据一致性。


3. 修改配置文件:三处必改参数决定能否微信支付成功

配置文件位于config/目录,共 4 个 PHP 数组文件。其中database.php和wechat.php是启动前必须修改的,app.php控制调试模式,cache.php可留默认。

3.1config/database.php:PDO 连接字符串的四个关键字段

return [ 'host' => '127.0.0.1', // 必须是 IP,localhost 在某些 PHP 8.1 环境下解析失败 'dbname' => 'food_db', // 必须与创建的数据库名完全一致(含大小写) 'username' => 'root', // 推荐新建专用用户,但开发期可用 root 'password' => 'your_pass', // 密码含特殊字符(如 @ / $)需 URL 编码,例:pass@123 → pass%40123 'charset' => 'utf8mb4', // 必须,否则 emoji 和长中文菜名存入失败 ];

参数说明:charset设为utf8mb4是硬性要求。若设为utf8(MySQL 的别名),插入“🌶️酸汤肥牛”时会截断为“??酸汤肥牛”,且dishes.description字段定义为TEXT,实际存储长度受限。

3.2config/wechat.php:微信支付 V3 接口的三个存活命门

此项目使用微信官方 PHP SDK(已内嵌在vendor/wechatpay/),但需你填入真实凭证:

return [ 'app_id' => 'wx1234567890abcdef', // 公众号 AppID(不是商户号!) 'mch_id' => '1234567890', // 微信支付商户号(10位纯数字) 'mch_cert_path' => '/var/www/food/cert/apiclient_cert.pem', // 证书路径(绝对路径!) 'mch_key_path' => '/var/www/food/cert/apiclient_key.pem', // 私钥路径(绝对路径!) 'api_v3_key' => 'your_32char_api3key_here', // APIv3密钥(微信商户平台设置) ];

血泪经验:mch_cert_path和mch_key_path必须是绝对路径,且 PHP 进程用户(通常是www-data)要有读取权限。常见翻车点:证书放在public/下被 Nginx 拒绝访问,或路径写成相对路径./cert/...导致file_get_contents()报错failed to open stream。

3.3config/app.php:调试开关与 URL 基础路径

return [ 'debug' => true, // 开发期设为 true,错误信息直接输出 'url' => 'http://food.local', // 必须填写,用于生成支付回调 URL 和邮件链接 'timezone' => 'Asia/Shanghai', // 时区影响订单时间戳,务必设准 'cookie_domain' => '.food.local', // 若用子域名(如 admin.food.local),此处需设为 .food.local ];

注意:url参数参与生成微信支付notify_url,若此处写localhost或127.0.0.1,微信服务器无法回调,订单永远卡在 “支付中”。


4. 启动与首单测试:从首页到支付成功,五步闭环验证

部署完成≠能用。必须走通一条完整链路:浏览菜单 → 加入购物车 → 提交订单 → 支付 → 查看订单状态。这五步缺一不可,每步都有隐藏陷阱。

4.1 首页加载:检查public/index.php是否正确加载路由

访问http://food.local,应看到响应式菜单页。打开浏览器开发者工具 → Network 标签页,刷新页面,观察:

  • index.php返回状态码 200,Response Headers 中含Content-Type: text/html; charset=UTF-8
  • assets/css/app.css和assets/js/app.js加载成功(状态码 200)
  • 若app.js404,检查public/assets/目录是否存在,或 Nginxroot是否指向public/

若首页空白且控制台报Uncaught ReferenceError: Vue is not defined,说明app.js未加载,大概率是public/assets/路径错误或文件权限问题(chmod -R 755 public/assets)。

4.2 添加菜品到购物车:Vue 组件如何与 PHP API 通信

点击“加入购物车”按钮,浏览器 Network 中应出现 POST 请求到/api/cart/add,Payload 为 JSON:

{"dish_id": 1, "quantity": 2}

后端app/Controller/CartController.php中add()方法接收此请求,关键逻辑:

// app/Controller/CartController.php public function add() { $input = json_decode(file_get_contents('php://input'), true); $dishId = (int)$input['dish_id']; $quantity = (int)$input['quantity']; // 1. 查询菜品是否存在且上架 $dish = (new Dish())->find($dishId); if (!$dish || $dish['status'] != 1) { return $this->json(['code' => 404, 'msg' => '菜品不存在或已下架']); } // 2. 写入 session(非数据库!购物车暂存在 PHP session) $cart = $_SESSION['cart'] ?? []; $cart[$dishId] = ($cart[$dishId] ?? 0) + $quantity; $_SESSION['cart'] = $cart; return $this->json(['code' => 0, 'msg' => '添加成功']); }

逻辑说明:购物车数据存在$_SESSION中,避免频繁读写数据库。$this->json()是 Controller 基类封装的 JSON 响应方法,自动设置Content-Type: application/json。

4.3 提交订单:orders表插入前的三重校验

点击“去结算”,POST 到/api/order/create,Payload 含地址 ID、支付方式、备注。后端OrderController.php执行:

  1. 库存校验:遍历购物车中每个菜品,查dishes.stock是否 ≥ 需求数量
  2. 地址校验:addresses.user_id必须等于当前登录用户 ID(防越权)
  3. 金额校验:重新计算菜品总价(防前端篡改 price)

若任一校验失败,返回{'code': 400, 'msg': '库存不足'}。成功则插入orders表,并生成order_no(格式:FD20240520123456,年月日+6位随机数)。

4.4 微信支付跳转:/api/pay/unifiedorder的签名生成逻辑

支付请求由前端 JS 触发,调用/api/pay/unifiedorder,后端生成微信统一下单参数:

// app/Service/PaymentService.php public function unifiedOrder($orderNo, $totalFee, $openid) { $params = [ 'appid' => config('wechat.app_id'), 'mch_id' => config('wechat.mch_id'), 'nonce_str' => $this->generateNonceStr(), // 随机字符串 'body' => '外卖订单', 'out_trade_no' => $orderNo, 'total_fee' => $totalFee * 100, // 单位:分 'spbill_create_ip' => $_SERVER['REMOTE_ADDR'], 'notify_url' => config('app.url') . '/api/pay/notify', // 回调地址 'trade_type' => 'JSAPI', 'openid' => $openid ]; $params['sign'] = $this->generateSign($params); // 关键:按微信规则生成签名 return $this->postWechatApi('https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', $params); }

参数说明:total_fee必须是整数分,notify_url必须是公网可访问地址(开发期可用 ngrok 映射,但本项目建议先用余额支付绕过)。签名算法必须严格遵循微信文档:参数按 key ASCII 升序拼接 +&key=xxx,再 MD5。

4.5 支付回调验证:/api/pay/notify如何防伪造请求

微信服务器 POST 到/api/pay/notify,Body 是加密 XML。后端必须:

  1. 读取原始 XML(file_get_contents('php://input'))
  2. 解密并验签(用config/wechat.php中的api_v3_key)
  3. 更新orders.status为'confirmed'
  4. 返回<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>

若漏掉第 2 步验签,攻击者可伪造支付成功通知,恶意修改订单状态。


5. 避坑指南:生产环境上线前必须解决的五个致命问题

部署到线上服务器后,看似能用,实则暗藏雷区。以下是我在三家客户现场踩过的坑,按发生频率排序:

5.1 现象:首页 CSS/JS 404,但文件明明存在

原因:Nginxroot指向了项目根目录(如/var/www/food),而非public/子目录。导致请求/assets/app.css实际去找/var/www/food/assets/app.css,而真实路径是/var/www/food/public/assets/app.css。
解决:确认 Nginx 配置中root值为/var/www/food/public,且location /块中try_files规则正确。

5.2 现象:微信支付回调 404,/api/pay/notify无法访问

原因:PHP-FPM 配置中security.limit_extensions默认只允许.php,而微信回调是 POST 到/api/pay/notify(无扩展名),被 Nginx 拒绝转发。
解决:编辑/etc/php/8.1/fpm/pool.d/www.conf,添加:

security.limit_extensions = .php .html .htm .js .css .png .jpg .gif .svg .woff .ttf

然后sudo systemctl restart php8.1-fpm。

5.3 现象:多用户同时下单时,订单号重复(FD20240520123456出现两次)

原因:generateOrderNo()方法用mt_rand(100000, 999999)生成 6 位随机数,当并发 > 1000 QPS 时碰撞概率显著上升。
解决:改用更可靠的方案,在app/Helper/OrderHelper.php中:

public static function generateOrderNo() { return 'FD' . date('Ymd') . substr(md5(microtime(true) . mt_rand()), 0, 6); }

利用microtime(true)提供微秒级熵值,彻底规避碰撞。

5.4 现象:菜品图片上传后显示乱码,URL 中含%EF%BF%BD

原因:<input type="file">提交时,PHP$_FILES中name字段含中文,但move_uploaded_file()不处理编码,直接保存为 UTF-8 字节流,Nginx 以 Latin-1 解析导致乱码。
解决:在app/Controller/DishController.php的uploadImage()方法中,对文件名做转码:

$originalName = $_FILES['image']['name']; $safeName = iconv('UTF-8', 'GBK//IGNORE', $originalName); // 先转 GBK $ext = pathinfo($safeName, PATHINFO_EXTENSION); $newName = uniqid() . '.' . strtolower($ext); move_uploaded_file($_FILES['image']['tmp_name'], UPLOAD_PATH . $newName);

5.5 现象:后台登录后,点击“订单管理”报Class 'App\\Controller\\AdminController' not found

原因:app/Controller/AdminController.php文件存在,但public/index.php中的自动加载器未注册App\\命名空间。
解决:检查public/index.php开头的 autoload 函数:

spl_autoload_register(function ($class) { $prefix = 'App\\'; $base_dir = __DIR__ . '/../app/'; $len = strlen($prefix); if (strncmp($prefix, $class, $len) !== 0) { return; } $relative_class = substr($class, $len); $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php'; if (file_exists($file)) { require $file; } });

确保$prefix和$base_dir路径正确,且文件名与类名严格一致(AdminController.php内必须是class AdminController)。


6. 进阶技巧:把“见面美观”变成“持续可用”,三个实战加固点

上线不是终点,而是运维起点。我给客户做的加固,从来不是加功能,而是让现有功能更稳、更省、更易维护。

6.1 订单状态机:用状态迁移表替代 if-else 堆砌

原始代码中,订单状态变更散落在各 Controller:

// 订单确认 if ($order['status'] == 'pending') { $order['status'] = 'confirmed'; } // 骑手接单 if ($order['status'] == 'confirmed') { $order['status'] = 'accepted'; } // 配送完成 if ($order['status'] == 'accepted') { $order['status'] = 'delivered'; }

这极易出错。我改成状态迁移表(config/order_status.php):

return [ 'pending' => ['confirmed'], 'confirmed' => ['accepted', 'cancelled'], 'accepted' => ['delivered', 'cancelled'], 'delivered' => [], 'cancelled' => [] ];

然后封装OrderService::transition($orderId, $toStatus):

public function transition($orderId, $toStatus) { $order = $this->find($orderId); $allowed = config('order_status.' . $order['status']) ?? []; if (!in_array($toStatus, $allowed)) { throw new Exception("状态 {$order['status']} 不允许迁移到 {$toStatus}"); } $this->update($orderId, ['status' => $toStatus]); }

效果:新增“退款中”状态只需改配置表,无需动任何业务逻辑。所有状态变更集中管控,审计日志也有了统一入口。

6.2 购物车持久化:从 Session 迁移到 Redis,支撑千人并发

Session 方案在单机 OK,但负载均衡后失效。我用 Redis 替代:

  1. 安装php-redis扩展
  2. 修改app/Service/CartService.php:
private $redis; public function __construct() { $this->redis = new Redis(); $this->redis->connect('127.0.0.1', 6379); } public function get($userId) { $key = "cart:{$userId}"; $data = $this->redis->get($key); return $data ? json_decode($data, true) : []; } public function set($userId, $cart) { $key = "cart:{$userId}"; $this->redis->setex($key, 3600, json_encode($cart)); // 1小时过期 }
  1. 登录后,$_SESSION['user_id']作为 Redis Key 前缀,天然隔离用户数据。

参数说明:setex设置 3600 秒过期,避免 Redis 内存无限增长;cart:{user_id}Key 命名规范,便于监控和清理。

6.3 日志分级:用 Monolog 替代error_log(),定位慢接口

原始日志全打到php_error.log,无法区分业务日志和错误。我引入轻量 Monolog(仅 1 个文件):

// app/Logger.php use Monolog\Logger; use Monolog\Handler\StreamHandler; class Logger { private static $instance; public static function get() { if (!self::$instance) { self::$instance = new Logger('food'); self::$instance->pushHandler(new StreamHandler('/var/log/food/app.log', Logger::INFO)); self::$instance->pushHandler(new StreamHandler('/var/log/food/error.log', Logger::ERROR)); } return self::$instance; } }

在OrderController.php中:

Logger::get()->info('订单创建', ['order_no' => $orderNo, 'user_id' => $userId, 'total' => $total]); Logger::get()->error('支付失败', ['order_no' => $orderNo, 'err_msg' => $e->getMessage()]);

效果:app.log记录所有订单行为,error.log仅存致命错误,配合tail -f /var/log/food/error.log实时盯盘,比看 Nginx error_log 高效十倍。

我坚持一个习惯:每次上线前,用ab -n 100 -c 10 http://food.local/api/menu/list压测菜单接口,看error.log是否有 Warning。没有 Warning 的系统,才敢让老板用手机下单。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询