PHP微信小程序SaaS扫码点餐与外卖配送系统实战

发布时间:2026/9/14 14:40:25
PHP微信小程序SaaS扫码点餐与外卖配送系统实战 简介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_id和table_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_id1001table_noA12 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_id、spec_name、price_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_id和specs对照数据库里的真实价格重新逐项计算并校验。计算完总价后再生成订单号调用微信支付统一下单接口。支付完成后订单状态从「待支付」改为「待接单」。此时有两个动作需要同时触发推送给商户端如果是商户小程序通过订阅消息如果是传统收银机走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采用的方案因为商户量级在几千到几万时单库单表配合索引完全扛得住选项三共享数据库、独立schemaPostgreSQL支持较好但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_options的price_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 外卖配送中的距离计算与配送费外卖配送需要判定商户的配送范围。假设每个商户在后台配置一个中心经纬度lng、lat和最大配送半径公里。用户下单时小程序端用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_token和openid绑定关系因为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.request的url不允许带端口默认只能访问443端口如果 PHP 服务跑在8080端口小程序上线后也是无法访问的。5.4 性能优化页面加载与图片处理扫码点餐第一屏是菜单用户等不了3秒。除了接口缓存外图片往往是最大拖累。菜品图片每张几百KB一屏二十个菜就是几MB。合理做法是上传菜品图片时就用 PHP 生成多尺寸缩略图列表页用120x120的小图详情页用640的图。PHP 处理图片可以用 GD 库或者 ImagickGD 库简单场景够用但对高质量JPEG的缩放效果略差。# 用imagick生成缩略图 convert input.jpg -resize 120x120! -quality 80 output_120.jpg小程序端图片组件使用lazy-load属性并给image设置modeaspectFill这样用户滑动菜单时只加载可视区域附近的图片。同时注意wx.request返回的数据里不要把图片URL拼成相对路径必须全量返回HTTPS绝对地址否则在小程序内无法显示。6. 第三方配送对接时的签名与回调验签技巧外卖配送环节里如果你接的不是自己养的骑手团队大概率要对接第三方配送开放平台。这类平台的接口几乎都采用「AppKey AppSecret 签名」的认证方式签名算法一般是把业务参数按key字典序排列拼接后加盐再MD5或HMAC-SHA256。PHP侧必须写一个统一的签名工具类避免每个接口各写一遍签名逻辑。以常见的配送开放平台为例创建配送单需要传shop_id、origin_lng、origin_lat、dest_lng、dest_lat、receiver_name、receiver_phone、weight、note等字段。签名时先把所有非空参数按参数名ASCII码升序排列拼接成k1v1k2v2格式末尾拼接app_secretxxx再做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分钟没有状态变更的订单主动向第三方平台查询配送进度。本文还有配套的精品资源点击获取