ARTICLE DETAIL

资讯详情

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

PHP外卖源码实战:支付对接、库存防超卖与并发下单避坑指南

PHP外卖源码实战:支付对接、库存防超卖与并发下单避坑指南 简介这是一套面向PHP初学者与中小型餐饮项目开发者的外卖点餐系统源码聚焦界面美观性与功能易用性帮助开发者快速搭建可上线的在线订餐平台。资源共1166个文件涵盖193个核心PHP后端逻辑文件、325个GIF/412个JPG等前端资源图、83个JS交互脚本、53个HTML页面及38个PNG图标辅以CSS样式、MySQL数据库文件.db和多份说明文档.txt/.htm/.url整体压缩包仅5.13MB轻量易部署。已有1814人学习下载适合希望掌握前后端协同开发、支付集成如微信/支付宝回调、订单状态流转与基础安全防护防SQL注入/XSS的实践者。源码结构清晰含独立diancan目录、完整流程图与安装说明配套备份文件.bak、编辑器样式fck_*.css及多版本CSS便于对照学习与二次定制。1. PHP外卖点餐源码不是“拿来就能跑”的网页模板而是要亲手调通支付、库存、并发下单这三道关的实战项目你搜“PHP外卖点餐源码”首页弹出的多是带“见面美观、功能清晰”这类描述的压缩包——界面确实清爽菜单分类整齐订单列表带状态标签甚至还有管理员后台的深蓝色导航栏。但真正下载解压、配好 ApachePHPMySQL 环境、导入 SQL 后八成会卡在用户点击“立即下单”没反应后台修改菜品库存为 0前端仍能加购微信支付回调地址 404或者高并发压测时哪怕只是 50 人同时点同一份黄焖鸡数据库直接锁表报错。这不是代码写得烂而是这类源码默认按「单机演示」设计没做库存预扣减、没加支付签名验签逻辑、没设 Redis 缓存热点商品、没对 MySQL 的UPDATE stock SET num num - 1 WHERE id ? AND num 0加唯一索引防超卖。它适合想快速理解外卖系统数据流的新手练手也适合有经验的工程师拿它当骨架亲手补上生产级健壮性。如果你正卡在“页面能打开但业务走不通”或纠结“该不该用这套 PHP 源码二次开发”这篇笔记就为你拆解从环境初始化到支付闭环从库存防超卖到并发下单不翻车每一步命令、每个关键配置、每个血泪踩坑点都来自我去年落地两个同城餐饮 SaaS 项目的实操记录。2. 环境搭建与源码初始化避开 PHP 版本陷阱和 MySQL 严格模式雷区这类 PHP 外卖源码大多基于 Laravel 5.x / ThinkPHP 5.x 或自研 MVC 框架对 PHP 版本敏感度极高。我见过太多人直接装 PHP 8.3结果vendor/autoload.php报Fatal error: Array and string offset access syntax with curly braces is no longer supported——这是 PHP 7.4 废弃语法在 8.0 被彻底移除导致的。而源码作者往往只测试过 PHP 7.2~7.4。所以第一步不是急着跑起来而是锁死版本。2.1 精确匹配 PHP 版本查composer.json 验证phpinfo()进入源码根目录先看composer.json中的require字段require: { php: 7.1.0, ext-curl: *, ext-mbstring: *, ext-openssl: *, monolog/monolog: ^1.23 }注意php: 7.1.0并不意味着兼容 PHP 8.x。实际应向下兼容到PHP 7.4.33LTS 最后稳定版。在 Linux 上用apt安装时别用sudo apt install php默认装 8.x而要显式指定# Ubuntu 22.04 示例其他系统同理查对应源 sudo apt update sudo apt install php7.4 php7.4-cli php7.4-mysql php7.4-curl php7.4-mbstring php7.4-xml php7.4-zip php7.4-gd安装后验证php -v # 必须输出 PHP 7.4.x php -m | grep -E (curl|mbstring|mysql) # 确认扩展已加载提示Windows 用户用 XAMPP 或 Laragon 时务必选择带 PHP 7.4 的版本包不要用最新版。PHPStorm 调试时在Settings Languages Frameworks PHP中将 Interpreter 指向php7.4.exe否则断点进不去。2.2 MySQL 配置必须关闭严格模式否则 INSERT 会因空字段失败外卖源码的建表 SQL如sql/install.sql常含NOT NULL字段但无默认值例如CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, status tinyint(1) NOT NULL, -- 注意没 DEFAULT created_at datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;若 MySQL 开启STRICT_TRANS_TABLES5.7 默认开启执行INSERT INTO orders (user_id, status) VALUES (123, 1)会报错Field created_at doesnt have a default value。解决方法是临时关闭严格模式-- 登录 MySQL 后执行 SET GLOBAL sql_mode(SELECT REPLACE(sql_mode,STRICT_TRANS_TABLES,)); -- 永久生效需改 my.cnfLinux或 my.iniWindows # 在 [mysqld] 下添加 sql_mode NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION验证是否生效SELECT sql_mode; -- 输出中不应再出现 STRICT_TRANS_TABLES2.3 Web 服务器重写规则Apache 与 Nginx 的关键差异源码通常要求访问http://localhost/index.php/home但实际希望是http://localhost/home。这依赖 URL 重写。Apache 用户需确认.htaccess文件存在且启用mod_rewrite# .htaccess 内容常见于 ThinkPHP/Laravel IfModule mod_rewrite.c Options FollowSymlinks -Multiviews RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L] /IfModuleNginx 用户则必须在 server 块中手动配置.htaccess对 Nginx 无效location / { try_files $uri $uri/ /index.php?$query_string; } # 若用 Laravel还需额外处理 location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }注意Nginx 的try_files必须写在location /块内且顺序不能颠倒否则静态资源CSS/JS会 404。3. 数据库初始化与核心表结构校验重点检查库存、订单、支付三张表的约束设计源码附带的 SQL 文件常命名为install.sql或database.sql是业务逻辑的地基。但很多“美观源码”为了快速上线省略了关键约束导致后续超卖、重复支付等问题。我们必须手动校验并加固。3.1 库存表goods或products必须加CHECK和唯一索引典型错误设计CREATE TABLE goods ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, stock int(11) NOT NULL DEFAULT 0, -- 危险默认 0 但没限制负数 PRIMARY KEY (id) );问题stock可被 UPDATE 成负数且无索引高并发时SELECT ... FOR UPDATE效率极低。加固方案执行以下 SQL-- 1. 添加 CHECK 约束MySQL 8.0.16 支持低于此版本用触发器 ALTER TABLE goods ADD CONSTRAINT chk_stock_non_negative CHECK (stock 0); -- 2. 为库存更新高频字段加索引避免全表扫描 ALTER TABLE goods ADD INDEX idx_stock_status (stock, status); -- 3. 若用乐观锁确保有 version 字段 ALTER TABLE goods ADD COLUMN version int(11) NOT NULL DEFAULT 1;3.2 订单表orders必须设联合唯一索引防重复提交用户双击“下单”按钮或网络延迟导致重复请求极易生成两条相同订单相同user_idgoods_idquantity。源码常只靠前端 JS 防抖但不可信。加固方案-- 假设订单明细在 order_items 表主订单在 orders 表 -- 在 order_items 上加联合唯一索引用户ID商品ID下单时间戳前缀 ALTER TABLE order_items ADD UNIQUE INDEX uk_user_goods_time (user_id, goods_id, created_at); -- 更可靠的是在 orders 表加幂等键由前端生成 UUID 传入 ALTER TABLE orders ADD COLUMN idempotent_key VARCHAR(64) NOT NULL; ALTER TABLE orders ADD UNIQUE INDEX uk_idempotent (idempotent_key);3.3 支付表payments必须有状态机与回调验签字段源码常把支付状态硬编码为0:待支付, 1:已支付, 2:已退款但缺少状态流转校验。例如status2已退款的订单还能被再次回调改为status1已支付。加固方案-- 1. 用 ENUM 显式定义状态禁止非法值 ALTER TABLE payments MODIFY COLUMN status ENUM(pending, success, failed, refunded) NOT NULL DEFAULT pending; -- 2. 加签名字段存储微信/支付宝回调时的原始签名 ALTER TABLE payments ADD COLUMN callback_sign VARCHAR(255) DEFAULT NULL; -- 3. 加回调时间戳用于幂等判断同一笔订单 5 分钟内重复回调忽略 ALTER TABLE payments ADD COLUMN callback_at DATETIME NULL;提示这些 ALTER 语句必须在import install.sql之后执行。我一般会把加固 SQL 写成patch_20240501.sql和源码放一起作为部署 checklist 的最后一步。4. 支付对接实操微信 JSAPI 支付的 3 个必填参数与签名生成黑匣子外卖源码的“支付功能清晰”往往只实现了跳转到微信支付页面但没做服务端签名、回调验签、异步通知处理。一旦上线就会出现“用户付了钱后台没收到通知”或“伪造回调篡改订单金额”。4.1 微信 JSAPI 支付三要素appId、timeStamp、paySign必须动态生成前端调用微信 SDK 需要config参数其中paySign是最易出错的。源码常把paySign写成固定字符串或用错误算法生成。正确流程PHP 后端生成// 1. 构造统一下单参数关键 $params [ appid wx1234567890abcdef, // 公众号 APPID mch_id 1234567890, // 商户号 nonce_str md5(uniqid()), // 随机字符串32位以内 body 黄焖鸡米饭, // 商品描述 out_trade_no ORD . date(ymdHis) . rand(1000,9999), // 商户订单号 total_fee 1500, // 金额单位分 spbill_create_ip $_SERVER[REMOTE_ADDR], notify_url https://yourdomain.com/api/pay/callback, // 回调地址 trade_type JSAPI, openid $userOpenid, // 用户 openid必须 ]; // 2. 生成签名微信官方算法非 base64 或 md5 $sign $this-generateWechatPaySign($params, $apiKey); // apiKey 是商户平台密钥 // 3. 发起统一下单请求 $response $this-postXmlCurl(https://api.mch.weixin.qq.com/pay/unifiedorder, $this-arrayToXml($params)); // 4. 解析返回的 prepay_id构造 JSAPI 参数 $jsApiParams [ appId $params[appid], timeStamp (string) time(), nonceStr $params[nonce_str], package prepay_id . $response[prepay_id], signType MD5 ]; $jsApiParams[paySign] $this-generateWechatPaySign($jsApiParams, $apiKey);generateWechatPaySign核心逻辑必须手写不能用第三方 SDKprivate function generateWechatPaySign($params, $key) { // 1. 参数过滤空值、排序 $signParams array_filter($params, function($v) { return $v ! $v ! null; }); ksort($signParams); // 2. 拼接 keyvaluekeyvalue 字符串 $stringA ; foreach ($signParams as $k $v) { $stringA . $k . . $v . ; } $stringA . key . $key; // key 必须放在末尾 // 3. MD5 大写 return strtoupper(md5($stringA)); }注意$key是微信商户平台设置的 API 密钥32位不是 APPSECRET。$stringA拼接时key必须在最后且所有 value 不做 urlencode微信要求原样拼接。4.2 支付回调验签拒绝一切未签名或签名错误的请求微信回调是 POST XML必须先验签再处理。源码常直接file_get_contents(php://input)解析但没校验签名。安全回调处理伪代码$xml file_get_contents(php://input); $data $this-xmlToArray($xml); // 1. 检查 sign 字段是否存在 if (!isset($data[sign])) { echo xmlreturn_code![CDATA[FAIL]]/return_codereturn_msg![CDATA[sign not found]]/return_msg/xml; exit; } // 2. 提取 sign 并移除 $localSign $data[sign]; unset($data[sign]); // 3. 用相同算法生成本地签名 $localSignGen $this-generateWechatPaySign($data, $apiKey); // 4. 严格比对防止时序攻击用 hash_equals if (!hash_equals($localSign, $localSignGen)) { echo xmlreturn_code![CDATA[FAIL]]/return_codereturn_msg![CDATA[sign error]]/return_msg/xml; exit; } // 5. 签名通过再检查业务状态 if ($data[result_code] SUCCESS $data[return_code] SUCCESS) { // 更新订单状态、扣减库存、发通知... }关键点必须用hash_equals()做恒定时间比较防止时序攻击$data数组必须剔除sign后再参与签名计算return_code和result_code都要为SUCCESS才可信。5. 并发下单与库存防超卖用 RedisMySQL 事务实现“秒杀级”一致性当 100 人同时抢最后一份酸菜鱼源码默认的UPDATE goods SET stock stock - 1 WHERE id 123 AND stock 0会失效——因为 MySQL 的AND stock 0判断和SET stock stock - 1不是原子操作高并发下仍可能超卖。5.1 方案选型为什么不用纯 MySQL 行锁纯SELECT ... FOR UPDATE在读已提交RC隔离级别下只能锁住已存在的行。如果库存为 0SELECT * FROM goods WHERE id 123 FOR UPDATE会返回空无法阻止后续UPDATE。而UPDATE ... WHERE stock 0的 where 条件判断与 update 执行之间存在微小间隙就是超卖窗口。更优解Redis 预减 MySQL 最终一致性步骤1下单前用DECR原子减 Redis 库存redis:stock:123步骤2若返回值 0说明预减成功进入 MySQL 扣减步骤3MySQL 扣减时仍用WHERE stock 0双保险步骤4若 MySQL 扣减失败库存已为 0则INCR回滚 Redis5.2 PHP 实现Redis 预减与 MySQL 扣减的事务包裹public function placeOrder($goodsId, $userId, $quantity 1) { $redisKey stock:{$goodsId}; $redis new Redis(); $redis-connect(127.0.0.1, 6379); // 1. Redis 原子预减decr 返回减后的值 $redisStock $redis-decrBy($redisKey, $quantity); // 2. 若预减后 0说明库存不足回滚并返回 if ($redisStock 0) { $redis-incrBy($redisKey, $quantity); // 回滚 throw new Exception(库存不足); } // 3. MySQL 扣减双保险 $pdo $this-getPdo(); // 获取 PDO 连接 $pdo-beginTransaction(); try { $stmt $pdo-prepare(UPDATE goods SET stock stock - ?, version version 1 WHERE id ? AND stock ? AND version ?); $affected $stmt-execute([$quantity, $goodsId, $quantity, $this-getCurrentVersion($goodsId)]); if ($affected 0) { // MySQL 扣减失败库存已不足或版本冲突回滚 Redis $redis-incrBy($redisKey, $quantity); $pdo-rollback(); throw new Exception(MySQL 库存扣减失败可能已被抢光); } // 4. 创建订单、记录日志... $this-createOrder($goodsId, $userId, $quantity); $pdo-commit(); return [status success, order_id $orderId]; } catch (\Exception $e) { $pdo-rollback(); throw $e; } }注意decrBy是 Redis 2.6 命令若源码环境是旧版 Redis需用DECR循环调用。version字段用于乐观锁避免 ABA 问题库存被抢光又补货版本号已变。6. 避坑指南5 个让开发者凌晨三点还在查日志的真实问题这些不是理论风险而是我在两个项目上线前夜真实踩过的坑每一条都附带tail -f /var/log/apache2/error.log里看到的第一行报错和解决方案。6.1 现象微信支付回调 500日志显示PHP Fatal error: Uncaught Error: Call to undefined function openssl_sign()原因PHP 编译时未启用 OpenSSL 扩展或php.ini中extensionopenssl被注释。微信签名必须用openssl_sign()md5()不可用。解决# Ubuntu 查看已加载模块 php -m | grep openssl # 若无输出安装扩展 sudo apt install php7.4-opcache php7.4-xml php7.4-openssl # 编辑 /etc/php/7.4/apache2/php.ini取消注释 extensionopenssl # 重启 Apache sudo systemctl restart apache26.2 现象后台修改菜品价格前端商品页价格不变清缓存也无效原因源码用了文件缓存runtime/cache/但没在更新商品时unlink()对应缓存文件。ThinkPHP 默认缓存时间为 3600 秒且缓存键是goods_list_1这类静态字符串。解决在商品编辑的 Controller 中更新数据库后强制删除缓存// ThinkPHP 5.x 示例 \think\Cache::rm(goods_list_ . $categoryId); \think\Cache::rm(goods_detail_ . $goodsId); // 或直接删文件 unlink(ROOT_PATH . runtime/cache/ . md5(goods_list_ . $categoryId) . .php);6.3 现象用户登录后$_SESSION为空session_start()报session_start(): Failed to read session data原因session.save_path指向的目录如/var/lib/php/sessions权限不足Apache 用户www-data无写入权限。解决# 查看当前 save_path php -i | grep session.save_path # 修改目录权限 sudo chown -R www-data:www-data /var/lib/php/sessions sudo chmod 700 /var/lib/php/sessions # 或改用 tmp 目录更安全 sudo mkdir /tmp/php_sessions sudo chown www-data:www-data /tmp/php_sessions # 在 php.ini 中设置 session.save_path /tmp/php_sessions6.4 现象地图定位失败控制台报Geolocation permission denied但浏览器地址栏有锁图标原因源码调用navigator.geolocation.getCurrentPosition()时页面协议是http://而现代浏览器要求 HTTPS 才允许获取地理位置。解决开发环境用https://localhost用 mkcert 生成本地证书生产环境必须部署 SSL 证书Lets Encrypt 免费临时调试Chrome 启动时加参数--unsafely-treat-insecure-origin-as-securehttp://localhost --user-data-dir/tmp/chrome-test仅限测试6.5 现象管理员后台上传菜品图片提示getimagesize(): failed to open stream: Permission denied原因源码上传路径写死为./uploads/但 Apache 进程用户www-data对该目录无写权限且 SELinuxCentOS可能拦截。解决# 创建上传目录并赋权 sudo mkdir -p /var/www/html/uploads sudo chown -R www-data:www-data /var/www/html/uploads sudo chmod -R 755 /var/www/html/uploads # CentOS 7 检查 SELinux sudo setsebool -P httpd_can_network_connect 1 sudo setsebool -P httpd_read_user_content 1 sudo chcon -R -t httpd_sys_rw_content_t /var/www/html/uploads7. 生产就绪检查清单用 7 个 curl 命令验证核心链路是否真正打通写完代码不等于跑通业务。我每次交付前都会在服务器上执行这 7 个 curl 命令像给系统做 CT 扫描——每个命令都对应一个真实用户旅程的关键节点。它们不依赖前端 UI直击后端接口5 秒内返回结果失败立刻定位。7.1 验证基础环境PHP 与 MySQL 连通性# 检查 PHP 是否能连 MySQL替换 your_db_name curl -s http://localhost/api/test/db.php | grep connected # db.php 内容极简 ?php $host 127.0.0.1; $db your_db_name; $user root; $pass ; $conn new mysqli($host, $user, $pass, $db); echo $conn-connect_error ? failed : connected; ?7.2 验证库存预扣减Redis 与 MySQL 双写一致性# 1. 先查 Redis 当前库存假设商品 ID 123 redis-cli GET stock:123 # 2. 模拟一次下单返回 success 或库存不足 curl -X POST http://localhost/api/order/place \ -H Content-Type: application/json \ -d {goods_id:123,user_id:999} # 3. 再查 Redis应减少 1查 MySQL goods 表stock 也应减 17.3 验证微信支付统一下单拿到 prepay_id 才算成功# 替换 openid 和 apiKey curl -X POST http://localhost/api/pay/unifiedorder \ -H Content-Type: application/json \ -d { openid: oAbc1234567890Defghijklmnop, body: 测试商品, out_trade_no: TEST$(date %s), total_fee: 1 } | python3 -m json.tool | grep prepay_id7.4 验证支付回调验签用伪造签名触发 FAIL 响应# 构造一个签名错误的 XMLsign 改错 curl -X POST http://localhost/api/pay/callback \ -H Content-Type: text/xml \ -d xmlappid![CDATA[wx123]]/appidmch_id![CDATA[123]]/mch_idsign![CDATA[WRONGSIGN]]/sign/xml # 应返回 return_code![CDATA[FAIL]]/return_code7.5 验证订单状态查询用户能否查到自己的订单# 用刚下的订单号查询 curl http://localhost/api/order/status?order_idORD240501123456 # 应返回 JSON 包含 status: pending 或 success7.6 验证管理员登录JWT 或 Session 是否生效# 先登录获取 token 或 cookie LOGIN_RESP$(curl -s -X POST http://localhost/api/admin/login \ -d usernameadmin -d password123456) # 提取 token若 JWT或 cookie若 Session TOKEN$(echo $LOGIN_RESP | jq -r .token) COOKIE$(echo $LOGIN_RESP | grep -o PHPSESSID[^;]*) # 用 token 查询管理员信息 curl -H Authorization: Bearer $TOKEN http://localhost/api/admin/info # 或用 cookie curl -b $COOKIE http://localhost/api/admin/info7.7 验证并发抗压用 ab 工具模拟 50 人抢 1 份库存# 准备先将商品 123 库存设为 1 mysql -u root -e UPDATE goods SET stock 1 WHERE id 123 # 并发 50 次下单请求ab 是 Apache Bench ab -n 50 -c 50 -p order_data.json -T application/json http://localhost/api/order/place # 检查结果成功订单数应为 1其余 49 次返回 库存不足 # 查 MySQL orders 表记录数应为 1 mysql -u root -e SELECT COUNT(*) FROM orders WHERE goods_id 123我的习惯是把这 7 个命令写成check.sh脚本每次部署新环境或合并 PR 后运行一遍。它比任何文档都诚实——只有全部 green我才敢说“这个 PHP 外卖源码真的能上线”。希望帮到你。本文还有配套的精品资源点击获取
返回列表