☰
外卖系统源码全拆解:多端架构、部署实战与二次开发避坑指南
2026/10/10 7:42:17 网站建设 项目流程

简介:一套面向外卖平台开发者的完整开源整站源码,覆盖用户端、商户端、配送端、小程序与APP多端场景,既适合个人开发者学习全栈业务闭环,也适合初创团队快速搭建可运营的外卖系统。整套资源共2000个文件,以js业务逻辑、html页面结构、css样式及xml配置为主,压缩包约136.95MB,目录覆盖前端展示、后端接口与多端入口,便于对照分析和二次开发。目前已有933人学习下载。源码采用开源方式发布,商户端可管理商品、订单与配送状态,配送端支持接单与路线流转,APP采用原生与网页混合模式,小程序可嵌入微信生态完成订餐支付;开发者既能从后端源码掌握数据存储、业务逻辑和API设计,也能通过前端源码理解界面构建与交互优化,按自身商业场景定制功能并持续迭代。

1. 食刻外卖系统源码:不是拼凑 Demo,是能直接运营的完整闭环

做外卖系统最怕什么?怕下载的源码只有用户端页面,后端就一张 mock 图,商户端和配送端纯粹是摆设。这份「食刻外卖系统」号称整站开源,商家端、配送端、小程序、APP 全部打包,我拆完之后第一反应是:它确实把「用户下单 → 商户接单 → 骑手配送 → 结算分账」这条链路跑通了,不是拿一个网页模板糊弄人。

这套系统适合两类人:一是接了外卖平台开发项目但缺一套能交付的整包方案,二是运营方想搭自己的外卖平台、省掉每年几万的 SaaS 租用费。我的建议是你先看第 2 和第 3 章的架构和部署,再决定要不要下;下面我把每个端的职责边界、部署步骤和最容易翻车的地方全部拆开讲。

2. 系统架构拆解:三个端的边界与选型理由

2.1 整体技术栈与端角色划分

一套外卖系统之所以难做,是因为它天然是「多端 + 强实时」的系统。用户端要随时刷新订单状态,商户端要在大批量订单涌进来时不卡顿,配送端要抢单和更新轨迹,这三者对实时性的要求完全不同。

从源码看,这套系统的技术栈是「PHP 后端 + 多端前端分离」的典型方案。后端接口用 PHP 的 ThinkPHP 框架写,处理用户鉴权、订单流转、商户结算这些业务逻辑;用户端和商户端走微信小程序,配送端因为要频繁定位和轨迹上报,做得更接近原生体验;APP 端则是一套基于 uni-app 的跨端工程,编译出 Android 和 iOS 包。这个选型很聪明,小程序承担 C 端流量入口,APP 端覆盖那些不想受微信生态限制的用户。

我拆过不少这套技术栈的项目,最关键的判断点是:它是否把商家后台和配送端做了独立部署。很多源码所谓的「多端」,其实是把商户端做成小程序里的一个 tab,配送端用公众号 H5 代替,这会导致接单和抢单的实时推送完全不可控。这套食刻系统的做法是商家端和配送端各跑一个独立站点,通过 WebSocket 连接订单服务,压力隔离,至少在生产环境不会被用户端流量拖垮。

2.2 数据库设计核心:订单表与结算表

数据库是整个系统最值得看的部分。多数外卖系统的订单表只有 order_status 一个字段,从下单到完成全靠状态值硬切,但实际运营中「用户取消」和「商户拒单」同时发生怎么办?「骑手已取餐」但「用户申请退款」又怎么处理?

这套系统的 order 表里,订单状态字段的枚举值划分得很细(pending、confirmed、delivering、completed、cancelled、refunding),并且独立了一张 refund_record 表去记录退款流程。这意味着订单流转不是单线切换,而是允许「退款中」和「配送中」并行存在,等骑手确认送达后再走退款结算。看图理解,订单表和结算表是一对多关系,商家结算按订单明细聚合,而不是按订单总数预估。

从实际开发角度看,这种设计能帮你省掉很多麻烦:商户端可以独立查看某一笔订单的结算明细,配送端只需要关心订单的 delivery_status 字段而不需要理解整个退款状态机。

2.3 接口层设计:为何要单独跑一个推送服务

外卖系统对「订单状态同步」的要求极高:用户下单后,商户端必须在几秒内收到新订单提醒;骑手接单后,用户端要立刻看到骑手轨迹。HTTP 轮询在这个场景下几乎不可用,因为订单高峰时几千个商户同时轮询,后端连接数和数据库查询直接被打满。

我看了这套源码的 message 服务模块,它是把 WebSocket 独立封装成了一个推送服务,订单状态变更时通过 Redis 发布订阅(pub/sub)广播给对应端。用 Redis 当消息中间件而不是直接上 RabbitMQ,说明这套系统在控制部署成本:单体服务器也能扛住,不需要单独维护一套消息队列集群。

接口层还有一个值得注意的地方:商户端和配送端的接口鉴权不是同一个体系。商户端走的是 JWT 方式,配送端用的是 token + 设备绑定的方式,因为骑手的手机可能被多人使用(比如早晚班交接),设备绑定能避免账号异地登录直接把上一个骑手踢下线。

3. 本地部署实战:把源码跑起来的三步操作

3.1 环境准备:PHP 版本和扩展是第一个坎

部署这套系统之前,先确认你的本地环境。PHP 版本要求是 7.4 以上(源码里没有直接写死,但 ThinkPHP 6 在 7.4 以下跑会有语法兼容问题),需要安装的 PHP 扩展有:redis、pdo_mysql、openssl、mbstring、curl、fileinfo。

如果你是本地开发,建议直接用 PHPStudy 一次性装好 Nginx + MySQL 5.7 + PHP 7.4,省得手动配环境。我一般会这样操作:

# 以 Ubuntu 22.04 为例,安装 PHP 7.4 及扩展 sudo apt install php7.4-fpm php7.4-mysql php7.4-redis php7.4-mbstring # 重启 PHP-FPM 让扩展生效 sudo systemctl restart php7.4-fpm

提示:PHP 版本不要一上来就上 8.x,这套源码用了不少旧式写法(比如each()相关的兼容层函数),PHP 8 会直接抛致命错误,你在项目根目录跑php -v能快速确认版本。

环境配好后,把源码压缩包解压到 Web 根目录,比如D:/phpstudy_pro/WWW/sk_food。这里有个容易忽略的点:后端工程、商家端、配送端是三个独立的入口目录,不要混在一个站点根目录里跑,否则路由会相互干扰。

3.2 数据库导入与全局配置文件修改

后端工程根目录下有个config/database.php文件,这是所有数据库连接的集中配置。先在 MySQL 里建一个库,然后把源码包里的food.sql导入:

-- 创建数据库,注意字符集一定要 utf8mb4 CREATE DATABASE `food` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 导入数据表 USE food; SOURCE /path/to/sql/food.sql;

导入完成后,修改config/database.php中对应的连接参数:

// config/database.php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'food', 'username' => 'root', 'password' => 'your_password', 'charset' => 'utf8mb4', 'prefix' => 'sk_', ], ], ];

数据库配置这一层,新手最容易栽在prefix前缀上。源码里所有表名都带sk_前缀,如果你导入后发现表名没有前缀,查询全部报「表不存在」,那一定是导入了错误版本的 SQL 文件。这套源码的 SQL 文件有 120 多张表,导入时如果有报错,优先检查是不是 MySQL 版本太低无法解析 utf8mb4 的索引长度限制,MySQL 5.6 及以下必须把 innodb_large_prefix 打开。

3.3 后端启动、小程序端编译与运行

后端是标准的 ThinkPHP 入口,配置好虚拟主机后访问http://localhost/sk_food/public/index.php,能看到「接口服务运行中」之类的返回,说明后端已经起来了。接下来改小程序端。

小程序源码工程是个独立的wxapp目录,用微信开发者工具导入,然后修改utils/config.js里的接口地址:

// utils/config.js module.exports = { // baseUrl 改成你的后端接口地址 // 本地调试用 http://localhost/sk_food/public/index.php baseUrl: 'http://localhost/sk_food/public/index.php', // 小程序 APPID,替换成你自己的 appId: 'wx your appid', // 地图 key,用于用户端展示配送距离 mapKey: 'your amap key', };

逻辑说明:这个小程序的请求封装做了两层处理——第一层是request方法自动拼上 token 请求头,第二层是checkLogin拦截器,遇到 401 就跳转到登录页。你实际测试时如果发现接口通了但页面空白,多半是 link 里的baseUrl末尾少了/index.php,导致整个路由错位。

编译时建议先在开发者工具里勾选「不校验合法域名」,等全部调通后,再配置正式的 request 合法域名和业务域名。开发者工具模拟器里跑起来后,第一件事是用一个测试手机号登录,确认用户端能拿到短信验证码,再进入商户端测试接单流程。

4. 避坑指南:部署和联调中容易翻车的五个地方

4.1 小程序真机预览白屏:域名白名单没配全

现象:开发者工具里一切正常,扫码真机预览后首页加载不出来,接口请求全部报request:fail。

原因:微信真机环境强制校验 request 合法域名,开发者工具里勾选的「不校验合法域名」只在工具内生效。后端域名如果用了 IP 加端口号,比如http://192.168.1.10:8080,微信直接屏蔽。

解决:在微信公众平台「开发 > 开发设置 > 服务器域名」里把https://你的正式域名加进 request 合法域名,同时把 socket 合法域名也加上(推送服务用 WebSocket)。注意正式域名必须备案,否则配置后依然无法访问。本地调试时尽量不要真机测,用开发者工具 + localhost 最省事。

4.2 商户端登录后 Session 失效:Redis 缓存没启动

现象:商户端登录成功,但一刷新页面就重新跳转到登录页,或者操作几个按钮后突然提示「请重新登录」。

原因:系统的 session 和鉴权 token 存放在 Redis 中,本地环境没有启动 Redis 服务,导致 token 写入失败但登录接口又没报错,二次请求时读不到缓存直接认为未登录。

解决:本地检查 Redis 是否在运行:

# 检查 Redis 进程和端口 redis-cli ping # 返回 PONG 说明正常,如果没启动: redis-server /path/to/redis.conf --daemonize yes

提示:Windows 下的 PHPStudy 自带 Redis 扩展,但默认不开。去「软件管理」里确认 Redis 已安装并启动,比手动编译省太多时间。

4.3 骑手轨迹不动:地图 key 配错或没开启 WebSocket

现象:用户端能看到骑手接单,但地图上的轨迹点一直不动,或者是骑手明明在移动,用户端却显示离线状态。

原因:配送端上报位置用的是高德地图 WebSocket 服务,如果 key 的「WebSocket 服务」没开通,或者 key 的 Android 包名和实际打包配置不一致,位置数据就无法推送到用户端。另外,骑手在室内时 GPS 信号弱,系统没有用基站辅助定位,也会看起来像「不动」。

解决:去高德开放平台确认 key 已经勾选了 Web 服务、WebSocket 和 Android 定位 SDK 三个服务权限。Android 打包时检查AndroidManifest.xml里的包名和 key 绑定的是否一致,不一致直接换成不绑定包名的 key 先测通流程。

4.4 导入 SQL 后外键丢失:MySQL 版本和表引擎不匹配

现象:导入food.sql后,admin 后台能登录,但订单列表查不出来,直接报Table doesn't exist。

原因:SQL 文件里部分表用了 InnoDB 的外键约束,如果你导入时用的是 MySQL 5.5 或以下的版本,外键在低版本解析时会把后续建表语句跳过去,导致后面的表没建出来。

解决:导入前先source到临时库,执行完后用这条命令核对表数量:

SELECT COUNT(*) AS tables_count FROM information_schema.tables WHERE table_schema = 'food';

如果数量少于源码包说明里的表数量,删除数据库重新导入。导入时建议在 MySQL 命令行中用source而不是用 Navicat 直接跑,避免图形工具因 SQL 文件太大超时中断。

4.5 支付回调延迟:回调地址没有内网穿透

现象:用户在小程序里支付成功,但订单状态半个小时都没变,后台日志里根本没有回调记录。

原因:微信支付回调是微信服务器主动请求你的服务器,本地环境没有公网地址,回调直接失败。把回调地址写成了http://localhost/...,微信那边既无法解析也无法触达。

解决:本地开发先用内网穿透工具(常见做法是花生壳或 ngrok)映射一个公网地址,然后在商户平台的「支付回调地址」里填上这个临时域名。注意这里的回调地址只能填一个,建议直接用一个独立的子域名,等正式环境部署后再切到正式地址。

5. 二次开发关键点:订单流转与多端权限设计怎么改

5.1 订单状态机:从下单到结算的完整链路

拿到源码不要急着改 UI,先把订单状态机读透。整套系统的核心流转是这样的:用户提交订单后状态为 pending,系统自动向商户端推送新订单通知;商户点击接单后状态变为 confirmed,此时配送端可以看到这笔待抢单订单;骑手抢单后状态变为 delivering;用户确认收货后状态变为 completed。用户端在 confirmed 和 delivering 之间可以发起退款申请,但系统会先冻结结算而不是直接退款,等骑手确认送达后再走退款清算。

如果你要改业务规则,比如增加一个「商户备餐中」状态,需要在enum/OrderStatus.php中新增枚举常量,同时把 order 表中的状态字段注释补充上。不要把新的状态值硬编码在业务代码里,这套源码的状态判断分散在商城模块、配送模块和结算模块三处,统一走枚举类才能保证一致性。

5.2 配送端抢单逻辑:并发下的防重复

抢单是最容易出并发问题的场景。两个骑手同时点了抢单按钮,如果后端只用select判断订单状态再update,大概率两个请求都读到「待抢单」状态,最后都更新成功,订单就被抢了两次。

源码里解决这个问题的方式是「乐观锁 + 唯一约束」双保险:

// 抢单接口核心逻辑 $orderId = $request->param('order_id'); $riderId = $session->get('rider_id'); $affected = Db::name('order') ->where('order_id', $orderId) ->where('delivery_status', 0) // 只有待抢状态才能抢 ->update([ 'delivery_status' => 1, 'rider_id' => $riderId, 'grab_time' => time(), ]); if ($affected == 1) { // 抢单成功,走推送逻辑 pushToUser($orderId, '骑手已接单'); } else { // 抢单失败,返回已被抢 return json(['code' => 400, 'msg' => '手慢了,订单已被抢']); }

这段代码的关键在于where('delivery_status', 0)条件加进了update语句,数据库层面会锁定实际被更新的行。affected等于 1 表示只有一条记录被改,第二个请求执行时因为状态已经不是 0 而更新到 0 行,自然就失败了。这个方法比武断的「查询再更新」更靠谱,也不需要引入事务,性能上更优。

参数说明:delivery_status的枚举值中 0 表示待抢单,1 表示已接单,2 表示已取餐,3 表示已送达。如果你后续要加「仅限区域内骑手抢单」的功能,需要再加一个rider_region_id的过滤条件,否则跨区域的骑手也能抢到单,这在运营时是很大的投诉来源。

5.3 商户端权限:角色与菜单怎么分离

商户端的权限设计比用户端复杂,因为一个商户有店长、店员、配送员三种角色。店长能改营业状态和结算提现,店员只能操作接单和打印小票,配送员只能看到待配送的订单。

源码里的权限控制不是简单的is_admin布尔值,而是基于role_id关联menu_auth表。你如果想给某个角色增加菜单权限,需要先查角色的role_id,再往menu_auth里插入权限记录:

-- 给角色 id=2(店员)增加「订单导出」权限 INSERT INTO sk_menu_auth (role_id, menu_id) VALUES (2, 23); -- 查看角色当前有哪些菜单 SELECT m.menu_name FROM sk_menu_auth ma LEFT JOIN sk_menu m ON ma.menu_id = m.menu_id WHERE ma.role_id = 2;

这块我踩过坑的地方是:菜单表里的pid字段控制的是超级管理员的默认权限,你新加了一个菜单但没设置pid,导致该菜单不会出现在任何角色的菜单树里。添加菜单时记得把pid设为父级菜单的 ID,否则白加。

5.4 微信小程序登录获取手机号:密钥配置流程

小程序端登录获取手机号是外卖系统的核心能力,新用户进来直接一键登录,省掉输手机号的流失。源码里已经封装好了wxLogin方法,但你需要自己在微信公众平台配置好 AppSecret,并将它填到后端配置文件中。

实测中很多人 appid 和 secret 抄混了,小程序端的appId和后端用的应该是同一个。在微信后台拿到 AppSecret 后填到config/wechat.php中,同时把小程序端utils/config.js里的appId替换成你自己的,否则调用getUserPhone时接口会提示 appid 不匹配。

6. 上线前最后一道工序:压测与日志排错

系统跑通只能证明功能存在,上线前应该先做两件事:模拟多商户同时下单的压测,以及把日志级别从 debug 改成 info。压测这一步能提前暴露两个问题:数据库连接数是否够用、WebSocket 推送会不会因连接数过多而崩。

本地压测不需要复杂的工具,Apache JMeter 或者直接写一段 PHP 脚本模拟并发请求就够了。我一般用一个简单脚本打下单接口:

# 模拟 50 个并发同时创建订单 ab -n 200 -c 50 -T 'application/json' \ -p order.json \ http://localhost:8080/api/order/create

跑完之后重点看两个指标:Failed requests是否为 0,Time per request的平均响应时间是否低于 800ms。如果失败率超过 5%,优先检查数据库连接池配置和 Redis 连接数,这两个是最常见的瓶颈点。

日志排错方面,ThinkPHP 的运行日志在runtime/log目录,按日期分文件存储。排错的时候用一条命令快速过滤当天的异常信息:

grep -i "error\|exception" runtime/log/20250615.log | tail -50

重点看SQLSTATE开头的数据库异常和Permission denied之类的权限异常。代码里如果自己打了日志,建议看一眼Log::write()的内容是否包含敏感信息,比如用户手机号、支付回调的签名串,这些在正式环境最好不要落到日志文件。从那以后,我每次上线外卖类项目都强制走一遍「压测 → 日志扫描 → 关 debug 模式」的流程,这套动作能挡掉至少七成上线后的夜间告警。希望帮到你。

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

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

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

立即咨询