PHP微信小程序SaaS扫码点餐与外卖配送系统实战
2026/9/14 14:40:21 网站建设 项目流程

简介:PHP微信小程序SaaS系统源码包基于ThinkPHP6框架开发,定位餐饮与零售行业的一站式点餐配送解决方案。资源包约18.06MB,内含约2000个文件,其中PHP源码数量最多,同时包含HTML、JS、CSS等前端页面文件,以及SQL数据库脚本、环境配置和部署说明,方便开发者在本地或服务器上快速搭建运行环境。已有1216人学习,具有不错的参考热度。系统对接微信开放平台,支持小程序和公众号扫码授权,可在线DIY生成小程序并一键发布,同时提供堂食扫码点餐、店内排号取餐、外卖配送等核心功能,支持多门店以及商家自配送、达达、顺丰同城配送等渠道。还内置无限模板扩展机制,覆盖餐饮小吃、水果生鲜、服装护肤、零食百货、超市等场景,系统结构清晰,注释完整,可作为学习TP6开发或商业建站的完整代码参考。

1. 扫码点餐SaaS,为什么是PHP和小程序的组合

拿到「PHP微信小程序SaaS系统 - 扫码点餐外卖配送」这个标题,大多数人的第一反应是:这不就是个点餐后台加个小程序前端吗?实际拆开看,里面叠了三层技术命题——微信小程序的扫码拉起与订阅消息、PHP后端的多租户隔离、以及外卖配送的订单状态机。三层叠加后,复杂度不是加法而是乘法。尤其是SaaS模式,意味着同一套PHP代码要服务几十上百家餐厅,每家有自己的菜品、价格、打印机和配送范围,数据隔离一旦做不好,A店改了菜单B店跟着变,这在生产环境里是事故级别的问题。

这个标题适合谁?一类是有PHP基础、想接微信小程序项目的独立开发者,另一类是公司里要把现有单体点餐系统改造成多商户架构的工程师。PHP选型在2025年依然有现实理由:部署成本低、上手快、生态里现成的支付和微信SDK多,配合小程序端用uni-app或原生写法都能快速出活。但真正决定系统能不能卖出去的,不是前端界面多漂亮,而是扫码点餐的链路是否顺畅、SaaS租户隔离是否严密、外卖配送的异常订单能不能被及时发现。下面按「商户端小程序 → PHP多租户接口 → 扫码与配送双场景 → 上线排错」这条主线展开,每一节都给出能直接落地的方案和参数。

2. 小程序端扫码点餐的最小闭环:从「扫一扫」到「提交订单」

2.1 微信扫码如何定位到具体商户和餐桌

扫码点餐的小程序端,核心入口不是用户自己打开小程序,而是用微信「扫一扫」扫描桌贴二维码。这个二维码的内容需要精心设计。常见做法是生成一个weixin://链接或者普通HTTPS链接,链接里带上merchant_idtable_no两个参数。小程序通过wx.scanCode或者直接在onLoad里解析options.q拿到原始链接。

// pages/index/index.js Page({ onLoad(options) { // options.q 是微信扫普通链接二维码后自动解码出的完整URL if (options.q) { const query = decodeURIComponent(options.q) // 例:https://api.example.com/scan?merchant_id=1001&table_no=A12 const params = this.parseQuery(query) this.setData({ merchantId: params.merchant_id, tableNo: params.table_no }) this.fetchMenu(params.merchant_id) } }, parseQuery(url) { const query = url.split('?')[1] if (!query) return {} return query.split('&').reduce((acc, pair) => { const [key, value] = pair.split('=') acc[key] = value return acc }, {}) }, fetchMenu(merchantId) { wx.request({ url: `https://api.example.com/api/menu?merchant_id=${merchantId}`, success: (res) => { this.setData({ menu: res.data }) } }) } })

这里有个容易被忽略的坑:微信扫码普通链接二维码时,如果链接是动态的、每次扫码参数不同,必须在小程序后台把二维码的「二维码规则」配置成带通配符的形式,例如https://api.example.com/scan*。如果不带通配符,微信会拒绝跳转。另一个要点是decodeURIComponent,因为二维码里中文参数(比如餐桌号)会被URL编码,拿到后必须解码才能拿到正确的table_no

扫码进入后,用户看到的是该商户的菜单列表。菜单数据按分类分组,每道菜显示图片、价格、月售、口味标签。这里不建议小程序端直接渲染整棵分类树,最好是按分类懒加载,用户点击左侧分类再请求右侧菜品,减少首屏流量消耗。微信小程序请求并发数限制是10个,菜单图片如果太多,要用懒加载图片组件,避免一次性wx.request拉取所有图片。

2.2 购物车与订单提交的状态设计

点餐购物车和小程序商城购物车有一个本质区别:点餐购物车要记录的不只是「菜ID + 数量」,还有「规格选择」和「做法要求」。比如一份牛肉面要选「宽面/细面」,辣度选「微辣/中辣/特辣」,这些规格项在数据库里不能简单作为字符串拼接,否则后厨打印小票时无法结构化处理。合理的设计是规格选项使用JSON数组存储,每个选项包含spec_group_idspec_nameprice_delta,其中price_delta表示加价金额。

// 购物车数据结构 const cartItem = { dish_id: 101, dish_name: '红烧牛肉面', quantity: 1, specs: [ { group: '面条', option: '宽面', price_delta: 0 }, { group: '辣度', option: '中辣', price_delta: 0 }, { group: '加蛋', option: '加卤蛋', price_delta: 2 } ], remark: '不要香菜' }

提交订单时,小程序端不要只传subtotal金额,必须把cartItem数组整体传给后端,由后端重新计算总价。这是因为客户端金额可以被篡改,如果只信任前端传来的total_price,恶意用户完全可以低价下单。PHP后端要做的是:根据dish_idspecs对照数据库里的真实价格,重新逐项计算并校验。计算完总价后,再生成订单号,调用微信支付统一下单接口。

支付完成后,订单状态从「待支付」改为「待接单」。此时有两个动作需要同时触发:推送给商户端(如果是商户小程序,通过订阅消息;如果是传统收银机,走WebSocket),以及如果是外卖订单,进入配送调度流程。这两个动作应该用消息队列解耦,PHP里可以用Redis的List结构做简易队列,或者用RabbitMQ,避免支付回调里同步推送导致耗时过长。

2.3 订阅消息:一次订阅一次推送的边界

微信小程序订阅消息有个无情的规定:用户每次点击授权,只能给商户推送一次消息。这意味着如果用户在扫码点餐时授权了「下单成功提醒」,那么这次授权消耗完后,下次下单还需要再次授权。点餐场景里,用户更关心的是「餐好了」这种取餐通知,而不是下单成功通知。所以产品设计上,应该在用户提交订单后弹出授权框,授权模板选「取餐通知」,模板字段里带上thing1(桌号或取餐号)、time2(预计时间)、thing3(备注)。

# 小程序端请求订阅消息授权 wx.requestSubscribeMessage({ tmplIds: ['模板ID_取餐通知'], success(res) { // res['模板ID_取餐通知'] === 'accept' 表示用户允许 } })

这里有一个实战经验:不要一进小程序就让用户授权订阅消息,那会带来极高的拒绝率。最佳时机是支付成功之后、返回订单详情页的瞬间。因为此时用户已经完成了核心动作,心理防备最低,授权成功率会明显提升。另外,wx.requestSubscribeMessage必须在用户点击行为(如按钮点击)的同步回调里调用,不能在异步请求回调里调,否则会直接失败,这个坑在微信基础库2.10.0之后的版本依旧存在。

3. PHP后端SaaS多租户设计:一张表还是多张表

3.1 共享数据库与独立数据库的取舍

SaaS系统的核心是租户(tenant)隔离。选项一:每个商户独立数据库,隔离最彻底,但数据库连接数会被打爆,PHP-FPM每个进程都要维持不同连接,运维成本高;选项二:共享数据库、共享表,通过tenant_id字段区分,这是多数扫码点餐SaaS采用的方案,因为商户量级在几千到几万时,单库单表配合索引完全扛得住;选项三:共享数据库、独立schema,PostgreSQL支持较好,但MySQL并不适合大量schema。

扫码点餐场景里,推荐选项二,但要配合两个关键措施:第一,所有业务表(菜品、订单、购物车、打印机配置)必须有tenant_id字段,且建立复合索引时把tenant_id放在最左侧;第二,业务层必须统一注入租户过滤条件,不允许出现任何一次SQL忘了带tenant_id。如果团队有强迫症,可以在PHP的Model基类里用全局scope强制追加租户条件。

// app/Models/Scopes/TenantScope.php namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; class TenantScope implements Scope { protected $tenantId; public function __construct($tenantId) { $this->tenantId = $tenantId; } public function apply(Builder $builder, Model $model) { $builder->where($model->getTable() . '.tenant_id', $this->tenantId); } }

上面的TenantScope是 Laravel 里常见的做法,每个请求进来后从中间件里解析出当前商户的tenant_id,然后通过Model::addGlobalScope注入。这样即使开发者写Dish::where('status', 1)->get(),最终SQL也会自动带上tenant_id = ?。这个机制能挡住绝大多数因为疏忽造成的越权查询。

3.2 菜品与价格的多租户存储结构

同一个SaaS系统里,每家商户的菜品肯定不同,但即便菜品相同,价格也可能不同。设计菜品表时,需要把「菜名、图片、描述」这些不常变的属性和「价格、是否上架」这些因店而异的属性分开。一个商户可以拥有自己的菜品库,菜品与商户的关系是多对多还是直接归属?扫码点餐场景下,直接归属更简单:dishes表里带tenant_id,每个商户自己维护一份菜品数据。这样插入时无需关联,查询时最快。

但「分类」需要特殊处理。商户A的分类是「热菜、凉菜、主食」,商户B的分类是「烧烤、海鲜、酒水」。分类必须也要有tenant_id,或者在分类表里用is_system字段区分系统预置分类和商户自建分类。菜品表通过category_id关联分类表,查询菜单时按分类排序输出。

-- 菜品表(精简结构) CREATE TABLE dishes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id INT UNSIGNED NOT NULL, category_id INT UNSIGNED NOT NULL, name VARCHAR(100) NOT NULL, image_url VARCHAR(500) DEFAULT '', description TEXT, base_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_tenant_category (tenant_id, category_id, status) ); -- 规格组表:每个菜品可以有多组规格(如辣度、面型) CREATE TABLE dish_spec_groups ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id INT UNSIGNED NOT NULL, dish_id INT UNSIGNED NOT NULL, group_name VARCHAR(50) NOT NULL, is_required TINYINT NOT NULL DEFAULT 1 COMMENT '是否必选' );

说明一下:base_price是菜品基础价,规格加价单独存在dish_spec_optionsprice_delta里。计算购物车总价时,遍历购物车项的specs数组,累加每个选项的price_delta,再加上base_price乘以数量。这个计算逻辑必须在PHP服务端完成,所以上面的SQL里菜品的base_price加了索引未必有意义,但tenant_id + category_id + status的复合索引对菜单查询非常重要,因为用户进入小程序第一件事就是按分类拉取上架菜品。

3.3 中间件解析商户身份的完整流程

以 Laravel 为例,SaaS中间件需要处理三类请求:小程序API请求、商户后台请求、支付回调。支付回调没有用户上下文,不能要求登录态,得靠订单号反查租户。对于小程序API请求,鉴权用的是微信登录后的openid,但openid只能代表用户,不能代表当前用户操作的是哪个商户。因此每个请求必须带tenant_id参数,或者在登录时就把用户与默认商户绑定。

// app/Http/Middleware/ResolveTenant.php public function handle($request, Closure $next) { // 从请求头或参数中获取租户ID $tenantId = $request->header('X-Tenant-Id') ?: $request->input('tenant_id') ?: $this->resolveTenantFromUser($request); abort_if(!$tenantId, 403, '缺少租户标识'); // 注入到容器中,供全局Scope使用 app()->instance('current_tenant_id', (int)$tenantId); // 校验当前用户是否有权访问该租户 $this->authorizeTenantAccess($request, (int)$tenantId); return $next($request); }

一个常见的误用是:把tenant_id放在请求体里不加验签,导致用户把请求里的tenant_id改成别家商户的ID,就能看到别人的菜单甚至创建订单。所以中间件里必须做权限校验——对于扫码点餐用户,校验的是他这次的会话是否绑定过该商户;对于商户后台用户,校验的是该账号在用户表中绑定的tenant_id是否等于请求中的值。双重校验缺一不可。支付回调则是另一种情况,回调里只有订单号,需要先查订单表拿到tenant_id,再执行后续操作,绝不能从回调参数里信任任何租户标识。

4. 扫码点餐与外卖配送双场景的订单状态机

4.1 堂食扫码与外卖配送的状态差异

一条订单在整个生命周期里会经历多个状态。堂食扫码点餐的状态流:待支付 → 待接单 → 制作中 → 待取餐 → 已完成。外卖配送的状态流:待支付 → 待接单 → 制作中 → 待取餐 → 配送中 → 已完成,如果用户取消则是:待支付 → 已取消。两者的差异在于外卖状态多了「配送中」,而且「待接单」后商户如果不接单,超时后要自动退款。

状态机不能用简单字符串字段随意更新,否则会出现「已完成」又变成「制作中」的脏状态。推荐用独立的订单状态日志表记录每次状态变更,同时在订单表里只保存当前状态和一个status_version字段做乐观锁。每次状态流转时,校验当前版本号是否匹配,避免并发状态下两个请求同时把订单状态改到不同分支。

// 订单状态流转示例 $updated = Order::where('order_no', $orderNo) ->where('status', 'pending_accept') // 期望当前状态 ->where('status_version', $order->status_version) ->update([ 'status' => 'making', 'status_version' => $order->status_version + 1 ]); if (!$updated) { // 说明有人在并发环境里修改过订单 // 需要重新读取并决定是否允许流转 Log::warning('订单状态乐观锁冲突', ['order_no' => $orderNo]); }

上面的更新语句是原子操作,把「期望状态」作为更新条件,如果实际状态不是pending_accept,影响行数为0,即认为冲突。这样比先查再改更可靠。

4.2 外卖配送中的距离计算与配送费

外卖配送需要判定商户的配送范围。假设每个商户在后台配置一个中心经纬度lnglat和最大配送半径(公里)。用户下单时,小程序端用wx.getLocation获取用户位置,传给后端。后端计算商户与用户间的球面距离,超出范围则直接提示「超出配送范围,无法下单」。

球面距离计算不能用平面两点间距离公式,因为经度每度的地面距离随纬度变化。PHP里常见实现是Haversine公式:

// app/Services/DistanceService.php public function haversineDistance($lat1, $lng1, $lat2, $lng2) { $earthRadius = 6371; // 单位:公里 $dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1); $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) * sin($dLng / 2); $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); return $earthRadius * $c; }

计算出距离后,配送费可以用阶梯计费:3公里内收6元,超出部分每公里加2元,封顶20元。注意配送费计算属于业务规则,不能写在小程序端,因为用户修改位置参数后费用会失真。后端必须在提交订单时用最新位置重新计算配送费,并且在订单表里记录配送距离和费用快照,避免后续修改订单导致历史数据对不上。

4.3 小票打印与配送接单的异步推送

商户端收到新订单的方式,常见做法是在收银电脑上跑一个小程序或者网页后台,用WebSocket或轮询接收订单。但很多小餐馆没有收银电脑,只有一台手机,这时候用微信订阅消息推送给商户端小程序是最省钱的方式。商户端小程序订阅消息模板使用「新订单提醒」,推送时机在订单支付成功后触发。

PHP端推送订阅消息需要调用微信接口subscribeMessage.send,这里最常见的坑是access_token过期。多个PHP进程并发获取access_token会导致互相覆盖,所以必须把access_token缓存到Redis或文件,并加上过期时间(7200秒提前5分钟刷新)。另外,要给每个商户单独存储其小程序的access_tokenopenid绑定关系,因为SaaS系统里不同商户可能用的是不同的小程序账号,一个token不能通用。

// app/Services/WechatNotifyService.php public function sendNewOrderNotify($order, $merchant) { $accessToken = $this->getMerchantAccessToken($merchant->id); $url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={$accessToken}"; $data = [ 'touser' => $merchant->boss_openid, 'template_id' => $merchant->new_order_tmpl_id, 'page' => 'pages/orders/detail?id=' . $order->id, 'data' => [ 'thing1' => ['value' => mb_substr($order->order_no, -6)], 'amount2' => ['value' => $order->pay_amount], 'thing3' => ['value' => mb_substr($order->customer_note, 0, 20)] ] ]; // 发送HTTP请求... }

外卖配送场景下,商户接单后还需要通知配送员或者第三方配送平台。如果接的是聚合配送(如达达、美团跑腿),PHP后端需要在商户确认接单后,立即调用第三方配送平台的API创建配送单,并传入商户地址、用户地址、经纬度以及商品重量预估。这里需要注意:第三方的回调是异步的,一个配送单可能经历「待接单 → 已接单 → 取货中 → 已送达」多个状态,这些状态要维护到订单表的delivery_status字段,并在每次回调时更新主订单状态。

5. 上线前必须调通的四个关键点:缓存、并发、消息推送与抓包排错

5.1 菜品缓存与缓存穿透

SaaS系统里,热菜品的请求量远超普通接口。如果每个用户扫码进入小程序都直接查MySQL,数据库压力很快会到瓶颈。常见做法是把商户菜单整体缓存到Redis里,键名设计为menu:{tenant_id},过期时间设为5分钟。用户扫码后先查缓存,缓存不存在再查库并回填。

但这里存在缓存穿透风险:如果商户没有上架任何菜品,缓存里存的是空数组,下一次请求还是会查库。简单的解决办法是把空结果也缓存,设一个较短的过期时间比如60秒。更严重的穿透是恶意请求不断用不存在的tenant_id去查询,导致每次穿透到MySQL。拦截这种攻击需要在接口入口校验tenant_id是否在商户白名单内,不存在直接返回404,不进入缓存和DB逻辑。

// 菜单缓存读取 public function getMenu($tenantId) { $cacheKey = "menu:{$tenantId}"; $menu = Redis::get($cacheKey); if ($menu !== false) { return json_decode($menu, true); } $menuData = DB::table('dishes') ->where('tenant_id', $tenantId) ->where('status', 1) ->orderBy('sort_order') ->get(); Redis::setex($cacheKey, 300, json_encode($menuData)); return $menuData; }

5.2 支付回调的幂等性处理

微信支付回调可能会因为网络问题重复推送同一笔订单通知。PHP处理回调时,第一步不是入账,而是先查订单表当前状态,如果已经是「paid」或更后面的状态,直接返回SUCCESS,不再执行后续逻辑。这个判断要用数据库状态加锁,比如用SELECT ... FOR UPDATE锁定订单行,然后判断状态,避免两个回调同时执行导致重复加积分或者重复推送。

还有一个PHP上常见的坑:回调里验签失败时,不能直接返回SUCCESS,否则微信会认为通知成功,不再重发,导致订单实际未支付却在业务侧显示已支付。应当让微信收到失败响应后继续重试几次。实际开发经验是:支付回调处理逻辑里如果调用了外部接口(比如打印机、第三方配送),外部接口失败不能作为回调失败的依据,应该把失败记录下来或者用队列重试,而不是让微信反复推回调造成订单状态反复横跳。

5.3 微信小程序抓包与错误排查

小程序端联调时最痛苦的问题是 HTTPS 证书和域名白名单。PHP开发环境如果使用自签名证书,小程序真机请求会直接失败。常见的解决办法是在微信开发者工具里勾选「不校验合法域名」,但真机预览无法使用这个选项。线上问题排查时,需要抓包看HTTPS请求的内容。

抓包推荐用 Charles 或 Fiddler,但在微信小程序里抓HTTPS包需要安装 Charles 的CA证书到手机信任列表,并且在小程序后台将请求的域名加入业务域名。实际操作中,不少开发者遇到「连接服务器失败」的错误,优先去小程序后台查看服务器域名配置和 request 合法域名,确保域名ICP备案且已经配置SSL证书。还有一个排查点:wx.requesturl不允许带端口,默认只能访问443端口,如果 PHP 服务跑在8080端口,小程序上线后也是无法访问的。

5.4 性能优化:页面加载与图片处理

扫码点餐第一屏是菜单,用户等不了3秒。除了接口缓存外,图片往往是最大拖累。菜品图片每张几百KB,一屏二十个菜就是几MB。合理做法是上传菜品图片时就用 PHP 生成多尺寸缩略图,列表页用120x120的小图,详情页用640的图。PHP 处理图片可以用 GD 库或者 Imagick,GD 库简单场景够用,但对高质量JPEG的缩放效果略差。

# 用imagick生成缩略图 convert input.jpg -resize 120x120! -quality 80 output_120.jpg

小程序端图片组件使用lazy-load属性,并给image设置mode="aspectFill",这样用户滑动菜单时只加载可视区域附近的图片。同时注意wx.request返回的数据里不要把图片URL拼成相对路径,必须全量返回HTTPS绝对地址,否则在小程序内无法显示。

6. 第三方配送对接时的签名与回调验签技巧

外卖配送环节里,如果你接的不是自己养的骑手团队,大概率要对接第三方配送开放平台。这类平台的接口几乎都采用「AppKey + AppSecret + 签名」的认证方式,签名算法一般是把业务参数按key字典序排列,拼接后加盐再MD5或HMAC-SHA256。PHP侧必须写一个统一的签名工具类,避免每个接口各写一遍签名逻辑。

以常见的配送开放平台为例,创建配送单需要传shop_idorigin_lngorigin_latdest_lngdest_latreceiver_namereceiver_phoneweightnote等字段。签名时先把所有非空参数按参数名ASCII码升序排列,拼接成k1=v1&k2=v2格式,末尾拼接&app_secret=xxx,再做MD5并转大写。

// app/Services/DeliverySigner.php public function sign(array $params, $appSecret) { ksort($params); $stringToSign = ''; foreach ($params as $key => $value) { if ($value === '' || $value === null) continue; $stringToSign .= $key . '=' . $value . '&'; } $stringToSign = rtrim($stringToSign, '&'); $stringToSign .= $appSecret; return strtoupper(md5($stringToSign)); }

注意这里的空值处理——不是所有空值都跳过,有些平台规定空字符串也要参与签名。所以这套工具类应当根据平台文档微调:有的平台只跳过null,不跳过空字符串。签名算法实现完,一定要用平台提供的「签名校验工具」跑一遍样例数据,确认计算结果与平台示例一致后,再开始联调。

回调验签是另一个高危区。第三方配送平台在骑手接单、取货、送达时会异步通知你的服务器,通知里包含订单号和签名。验签时需要把接收到的POST参数(不包括签名本身)重新做一次签名,对比平台传来的sign字段是否一致。一致才进入状态更新逻辑,不一致则直接丢弃,并记录告警日志。

另外,配送回调接口对外暴露时,不能让第三方平台直接访问到你的主业务库所在的机器。建议通过内网负载均衡转发到PHP服务,且回调接口URL不使用通用域名,而是生成一个带随机路径的隐藏地址,比如https://api.example.com/delivery/cb/A8f3kX2p,增加被恶意扫描预测的难度。回调处理完必须输出{"status": "ok"}这样的内容,第三方平台收到非OK响应会认为回调失败并重试;如果你在处理过程中因为业务异常返回了错误码,就要保证自己的逻辑是幂等的,否则重复配送会造成多发单。最后接单超时、骑手长时间未接单的轮询逻辑,可以放在 PHP 的cron脚本里,每分钟扫描一次配送中但超过5分钟没有状态变更的订单,主动向第三方平台查询配送进度。

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

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

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

立即咨询