PHP微信小程序SaaS:扫码点餐与外卖配送系统设计
简介一套基于ThinkPHP 6开发并已对接微信开放平台的PHP微信小程序SaaS系统源码主要面向餐饮小吃、水果生鲜、商超零售等行业的开发者与商家可快速搭建扫码点餐、排队取餐与外卖配送一体化的小程序应用。压缩包内共2000个文件以2252个PHP核心业务文件为主辅以HTML页面、JavaScript脚本、JSON配置及SQL数据库等资源整包约18MB目录结构包含配置文件、扩展函数、模板样式等适合多门店部署与二次开发。目前已有1200余人学习下载尤其适合需要小程序端与公众号端统一授权管理、支持商家自配送或达达、顺丰同城配送等场景。资源内含堂食扫码点餐、店内排号取餐、外卖订单流转、多门店支持等完整业务闭环并提供在线DIY小程序模板与源码生成发布机制便于快速适配餐饮与零售行业的个性化需求降低小程序开发成本。1. 扫码点餐小程序做成 SaaS第一步不是写页面一家店用着挺好的点餐小程序开第二家分店时才发现代码里写死了店名、桌台和菜品表复制一版接口又要改一遍这种交付方式做不了连锁也做不了招商。标题里这个“PHP微信小程序SaaS系统扫码点餐外卖配送”本质上是把一套 PHP 后端跑成多租户底座商户表、餐桌、菜品、订单都带上租户标识扫码点餐和外卖配送作为两条交易链路挂上去。你要解决的不是“写一个小程序”而是“多个商家共用一个后端各自管理自己的桌台和菜单”。这类系统最常见的实现形态是 PHP MySQL Redis小程序端用微信原生或 uniapp 微信小程序开发。下文中涉及的关键点按“租户设计 → 桌码链路 → 外卖状态机 → 交付排错”的顺序讲适合有 PHP 基础的开发者阅读也适合刚接手餐饮 SaaS 项目的朋友顺一遍设计思路。2. 数据模型与多租户隔离扫码点餐系统的表结构怎么定2.1 为什么先用“共享表 租户字段”而不是每个商家一套表餐饮扫码点餐的数据量单店一天几百单连锁几百家店也就是几万单级别远没有到非分库不可的程度。常见做法是单库共享表每张业务表加一个merchant_id字段按行隔离商户数据。这样一套代码、一张订单表就能服务几十上百个商家新商户入驻时只插一条记录不用建库建表。独立库、独立实例虽然隔离更彻底但运维成本高而且每次跨店查询都要从多个库里汇总数据。对“开分店、加盟商各自收银”这类场景来说共享表加租户字段是 ROI 最高的方案。它的真正风险是开发者写 SQL 时漏掉merchant_id条件导致 A 商户的餐桌或订单出现在 B 商户后台。这个风险靠代码规范兜底模型层统一加过滤服务层统一做上下文注入。下表是几个常见隔离方案的对比我一般在技术方案评审时直接拿出来用方案隔离粒度运维成本商户规模参考共享表 租户字段行级低几百到数千商户一商户一库库级高强合规、数据必须物理隔离独立实例服务级极高私有化交付、大客户专用2.2 orders 主表、merchant 和 dining_table 的核心字段点餐系统里最核心的是商户、桌台、订单三张表。商户表管租户基本信息和小程序支付参数桌台表管餐桌名称和二维码 scene订单表则把店内点餐和外卖配送统一收口。下面这张订单表结构覆盖了扫码点餐和外卖配送两条链路。-- 商户表租户主表 CREATE TABLE merchant ( id int(11) unsigned NOT NULL AUTO_INCREMENT, merchant_no varchar(20) NOT NULL COMMENT 商户编号订单号前缀用, name varchar(100) NOT NULL COMMENT 商户名称, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1营业 0停业, appid varchar(50) NOT NULL DEFAULT COMMENT 小程序appid, mch_id varchar(50) NOT NULL DEFAULT COMMENT 微信支付商户号, api_key varchar(64) NOT NULL DEFAULT COMMENT 支付api密钥, created_at int(11) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_merchant_no (merchant_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商户表; -- 桌台表 CREATE TABLE dining_table ( id int(11) unsigned NOT NULL AUTO_INCREMENT, merchant_id int(11) unsigned NOT NULL COMMENT 所属商户, name varchar(20) NOT NULL COMMENT 桌台名如A02, qr_scene varchar(64) NOT NULL DEFAULT COMMENT 小程序码scene落库便于复查, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1可用 0停用, PRIMARY KEY (id), KEY idx_merchant_scene (merchant_id, qr_scene) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT桌台表; -- 订单表店内扫码与外卖共用 CREATE TABLE orders ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, order_sn varchar(32) NOT NULL COMMENT 业务订单号, merchant_id int(11) unsigned NOT NULL COMMENT 租户ID, table_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 桌台ID外卖场景为0, order_type tinyint(1) NOT NULL DEFAULT 1 COMMENT 1店内点餐 2外卖配送, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0待支付 1待接单 2制作中/配送中 3已完成 4已取消, total_fee decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 菜品小计, delivery_fee decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 配送费店内为0, receiver_info varchar(500) NOT NULL DEFAULT COMMENT 外卖收货信息json, pay_time int(11) unsigned NOT NULL DEFAULT 0, finish_time int(11) unsigned NOT NULL DEFAULT 0, created_at int(11) unsigned NOT NULL DEFAULT 0, updated_at int(11) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_order_sn (order_sn), KEY idx_merchant_status (merchant_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;三个表的关联逻辑orders.merchant_id对所有业务查询生效后台、小程序、配送端全部拿它做数据裁剪而不是依赖session里的门店 ID 去“猜测”。dining_table.qr_scene存的是小程序码的 scene 参数打印桌码时写入用户扫码后前端把 scene 原样传给后端后端解析出商户和桌台。订单表把店内和外卖放在一起用order_type区分好处是财务对账、销量统计只需要查一张表。2.3 模型层统一加 merchant_id 的过滤多租户系统最忌到处写where(merchant_id, $merchantId)查漏一次就串数据。我一般会在模型层做一个统一查询范围让所有继承基类的模型在select/update/delete时自动带上租户条件。下面这段代码用的是 Laravel 风格的全局 ScopeThinkPHP 里改成模型事件或查询器封装思路完全一致。?php trait HasMerchantScope { // 模型创建时自动填充商户ID public static function bootHasMerchantScope(): void { static::creating(function ($model) { if (empty($model-merchant_id)) { $model-merchant_id self::currentMerchantId(); } }); // 读操作自动追加 merchant_id 条件 static::addGlobalScope(merchant, function ($builder) { $merchantId self::currentMerchantId(); if ($merchantId 0) { $table $builder-getModel()-getTable(); $builder-where($table . .merchant_id, $merchantId); } }); } protected static function currentMerchantId(): int { // 用户登录后把商户ID写入session或请求头这里统一取出 return (int) request()-header(X-Merchant-Id, 0); } }这段代码解决两个问题一是Order::create([...])不传merchant_id时自动补全防止新订单漏租户标识二是查询自动带过滤后台列表页、API 接口哪怕忘了手写 where也不会跨商户。注意DB::table(orders)-insert(...)这种查询构造器写法会绕过模型事件团队协作时要约定业务代码一律走模型不走原生 SQL。另外缓存 key 也要带商户维度例如dish_list_merchant_{id}否则 A 店改价会串到 B 店缓存。3. 桌面码到下单扫码点餐的接口链路和防重实现3.1 桌面小程序码用 getwxacodeunlimit 生成与 scene 解析扫码点餐的第一个动作是“扫桌码”。微信小程序码的 scene 参数总长受 32 个可见字符限制所以场景值不能直接拼 URL业界常见做法是约定短协议串。我把协议定成业务类型_商户ID_桌台ID例如1_10001_205这样小程序端拿到 scene 后原样传给 PHP后端一处解码。生成小程序码需要先取 access_token再调用微信wxa/getwxacodeunlimit接口。PHP 侧调用代码?php /** * 生成餐桌小程序码 * param int $merchantId 商户ID * param int $tableId 桌台ID * param string $accessToken 微信access_token * return string 返回图片文件名 */ function generateTableQrCode(int $merchantId, int $tableId, string $accessToken): string { // 1_商户ID_桌台ID字段之前用下划线分隔 $scene sprintf(1_%d_%d, $merchantId, $tableId); $url https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token . $accessToken; $payload json_encode([ scene $scene, page pages/order/index, check_path false, env_version release, // release正式版 trial体验版 develop开发版 width 430, ]); $ch curl_init($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, [Content-Type: application/json]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $resp curl_exec($ch); $httpCode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode ! 200) { throw new RuntimeException(微信接口请求失败: . $httpCode); } $data json_decode($resp, true); if (isset($data[errcode]) $data[errcode] ! 0) { throw new RuntimeException(生成小程序码失败: . $data[errmsg]); } // $resp 此时是图片二进制内容存储到本地或对象存储 $filename table_ . $merchantId . _ . $tableId . _ . date(Ymd) . .png; file_put_contents(/data/qrcode/ . $filename, $resp); return $filename; }这个接口请求成功时返回图片二进制失败时返回 JSONerrcode/errmsg所以判断逻辑要分开写。check_path设为 false 可以容忍页面还没发布上线时先扫码预览正式环境建议改回 true。scene拼接时不要带?或微信侧会做 URL Encode你只需要保证自己按“下划线分隔、字段顺序固定”来解析即可。3.2 小程序扫码后的餐位上下文绑定小程序端通过wx.scanCode或直接识别小程序码进入页面后onLoad(options)里的options.scene是经过编码的字符串。前端如果用了 uniapp 微信小程序开发这里的解析逻辑完全一致把 scene 解码后传给POST /api/table/bind接口PHP 校验桌台归属和营业状态再返回一个短时效餐位令牌。?php public function bindTable(Request $request) { $scene (string) $request-input(scene); // 格式: 1_商户ID_桌台ID $parts explode(_, $scene); if (count($parts) ! 3 || (int) $parts[0] ! 1) { return $this-fail(scene参数不合法); } $merchantId (int) $parts[1]; $tableId (int) $parts[2]; $table DiningTable::where(merchant_id, $merchantId) -where(id, $tableId) -where(status, 1) -first(); if (!$table) { return $this-fail(桌台不存在或已停用); } // 生成餐位令牌15分钟内有效后续点餐接口携带 $tableToken bin2hex(random_bytes(16)); cache()-put(table_token: . $tableToken, json_encode([ merchant_id $merchantId, table_id $tableId, ]), 900); return $this-ok([ table_token $tableToken, merchant [ name $table-merchant-name, logo $table-merchant-logo, ], ]); }餐位令牌不是用户登录态它只代表“当前这个用户坐在哪张桌”。有效期设 15 到 30 分钟都合理用户中途退出小程序再进来要重新扫码。有实力的商家会结合“最近 30 分钟内的 openid 绑定关系”自动恢复桌台但初版不要做容易造成用户明明在 B 桌却下单到 A 桌。对外卖场景用户上下文用openid而不是桌台点餐接口据此分流。3.3 提交订单与扣库存防重令牌、行锁和事务扫码点餐最典型的并发问题有两个顾客连续点两次“提交”产生重复订单以及热门菜品库存只有 5 份却同时卖出 10 份。前者用防重令牌解决后者用数据库行锁加事务解决。下面这段下单伪代码覆盖了这两个关键点。?php public function createOrder(Request $request) { $tableToken $request-input(table_token); $items $request-input(items); // [{dish_id:1,num:2,sku_id:11}] $remark $request-input(remark, ); $ctx $this-getTableContext($tableToken); if (!$ctx) { return $this-fail(餐位令牌失效请重新扫码); } $merchantId $ctx[merchant_id]; // 防重令牌前端每次进入下单页生成唯一串10秒内重复提交会被拦截 $idempotentKey $request-input(idempotent_key, ); if ($idempotentKey || Redis::set(order_idem: . $idempotentKey, 1, [NX, EX 10]) ! true) { return $this-fail(请勿重复提交); } try { DB::beginTransaction(); $totalFee 0; foreach ($items as $item) { $dish Dish::where(merchant_id, $merchantId) -where(id, $item[dish_id]) -lockForUpdate() -first(); if (!$dish || $dish-status ! 1) { throw new \RuntimeException(菜品已下架); } // stock-1 表示不限量其余表示实际剩余份数 if ($dish-stock 0 $dish-stock $item[num]) { throw new \RuntimeException($dish-name . 库存不足); } if ($dish-stock 0) { $dish-decrement(stock, $item[num]); } $totalFee bcadd($totalFee, bcmul($dish-price, $item[num], 2), 2); } $order new Order(); $order-order_sn $this-buildOrderSn($merchantId); $order-merchant_id $merchantId; $order-table_id $ctx[table_id]; $order-order_type 1; $order-status 0; $order-total_fee $totalFee; $order-created_at time(); $order-save(); DB::commit(); return $this-ok([ order_sn $order-order_sn, pay_fee $totalFee, ]); } catch (\Throwable $e) { DB::rollBack(); return $this-fail($e-getMessage()); } }防重令牌用的是 Redis 的NX EX原子操作同一个令牌只有第一次能写入成功后续请求直接失败。注意前端生成令牌的时机是进入下单页时而不是点击提交时否则网络慢的情况下用户连续点按还是会生成两个不同令牌。菜品行锁lockForUpdate()保证同一道菜在并发事务里串行处理减库存和校验必须放在同一事务内先锁行再读避免超卖。金额计算用bcadd/bcmul不要用float0.1 0.2这类精度问题在订单金额上不能妥协。4. 外卖配送的订单状态机、配送费与延时取消4.1 外卖与点餐共用菜单订单表怎么区分 order_type外卖和店内扫码点餐背后是同一套菜品和价格区别在于订单头外卖多了收货信息、配送距离、配送费且优惠策略通常不同。所以不需要建两套订单体系orders.order_type字段就能区分。店内订单table_id指向餐桌外卖订单table_id 0收货信息统一序列化到receiver_info。后端接口路由也按这个思路拆点餐链路走/api/order/create外卖链路走/api/order/express控制器里共用同一个订单服务类只有支付前校验不同。外卖场景必须校验收货地址经纬度是否在配送范围内这个判断放在下单前而不是支付后不然会出现顾客付了钱才发现超出配送范围的纠纷。4.2 配送距离和配送费Haversine 公式与阶梯计价配送费的实时计算依赖距离。第三方地图 API 能拿到真实骑行距离但每单多一次外部接口调用压力和费用都上去了。我一般会在下单前先用 Haversine 公式算直线距离做第一道风控真实距离差异再用一个系数修正。?php /** * Haversine 公式计算两点距离 * param float $lat1 商家纬度 * param float $lng1 商家经度 * param float $lat2 用户纬度 * param float $lng2 用户经度 * return int 距离单位米 */ function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): int { $earthRadius 6371000; // 地球平均半径单位米 $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 (int) round($earthRadius * $c); }计算逻辑上有个细节要留意Haversine 算出来的是球面直线距离实际骑行路径通常是它的 1.4 到 1.6 倍。行业里最快的近似方案是“直线距离 × 1.4再向上取整到百米”做满减门槛够用。配送费按阶梯设置时规则配在商户配置表里示例距离区间配送费0 ~ 1.5 km3.00 元1.5 ~ 3 km5.00 元3 ~ 5 km8.00 元超过 5 km8.00 (超出公里数 × 1.5) 元满额减免逻辑放在菜品小计之后total_fee 免配送门槛时delivery_fee直接置 0。配送费计算不要写死在 PHP 常量里因为每个商户的满减门槛不同存商户配置表的 JSON 字段里后台可改。4.3 超时未支付订单的取消Redis 延迟队列与 PHP 队列消费者外卖和点餐订单都会遇到“下单不支付”的僵尸单。简单做法是每分钟扫一次订单表status0且create_time超过 15 分钟但这种方式对表压力大而且时间不准。我用 Redis ZSet 做延迟队列下单时把订单写入队列score 用过期时间戳定时任务每隔 30 秒取一次到点订单再进 PHP 服务里取消并恢复库存。?php // 下单后加入延迟队列score 为绝对时间戳 Redis::zAdd(order:timeout:queue, time() 900, $orderSn); // CLI 定时任务crontab 每30秒执行一次 public function handleOrderTimeout() { $now time(); $expiredOrders Redis::zRangeByScore(order:timeout:queue, 0, $now); if (empty($expiredOrders)) { return; } foreach ($expiredOrders as $orderSn) { // 二次校验防止支付回调与取消逻辑并发执行 $order Order::where(order_sn, $orderSn)-first(); if ($order $order-status 0) { DB::transaction(function () use ($order) { // 恢复菜品库存 foreach ($order-items as $item) { $dish Dish::find($item-dish_id); if ($dish $dish-stock 0) { $dish-increment(stock, $item-num); } } $order-status 4; $order-save(); }); // 发送模板消息告知用户订单超时已取消 } Redis::zRem(order:timeout:queue, $orderSn); } }这个方案的几个参数和边界zAdd里score存的是绝对时间戳不是相对秒数zRangeByScore的区间是闭区间所以取到的是“当前时刻之前已过期”的订单不会误伤未来单。任务执行前要再读一次订单状态否则会出现“用户正在支付、这边已经取消”的极端竞态。如果项目里已经集成了 think-queue 或 RabbitMQ也可以用延迟消息实现同样的效果但 PHP 原生定时任务配合 ZSet 最轻量不依赖额外进程。5. 多商户交付前的四个坑与一个 scene 统一协议5.1 四个容易翻车的地方第一个坑是商户数据隔离只做了接口层没做缓存层。菜品的 Redis 缓存 key 必须拼上merchant_id同理还有首页轮播图、公告、配送费配置否则 A 商户改了价格B 商户的小程序上出现 A 的价格。排查时最直接的方法是两个商户各开一台手机同时登录后台改配置观察对方商户是否变脏数据。第二个坑是微信支付回调的重复通知。微信支付在订单成功后会多次推送回调回调处理不幂等就会出现“同一个订单加两次流水、库存扣两次”。处理方式是在回调里以out_trade_no查订单同时给流水表transaction_id建唯一索引插入冲突时直接返回成功。回调里的状态更新和流水写入必须同一个事务。多商户场景还要防止回调串号out_trade_no生成时带上商户号前缀例如10001_20260101120000123这样回调查单时能反推商户。第三个坑是商品图片域名和带宽。小程序对downloadFile合法域名有校验图片域名必须配置到小程序后台且要求 HTTPS。扫码高峰期并发请求图片很容易打满出口带宽图片上传时用 PHP 生成多尺寸缩略图是常规解法压缩和缩放可以扔进 PHP 队列异步处理。如果不做图床抽象后期换 CDN 时要在小程序端改域名很被动。第四个坑属于调试细节。电脑端微信开发者工具里wx.env.user_data_path的路径和真机不一致保存附件或本地调试时要注意区分环境商家后台做“配送员签到”这种长按手势时扫码点餐列表的滚动会和小程序原生长按拖拽冲突要考虑用scroll-view自带的scroll-y配合手势判断处理接口异常排查时最方便的是在电脑端微信开发者工具里直接看 Network 面板真机上的小程序需要抓包时可以用 Charles 配置 HTTPS 证书抓取电脑端微信小程序流量iOS 和 Android 的证书信任流程不同配置时留意。?php // 支付回调幂等处理的关键代码 DB::transaction(function () use ($outTradeNo, $transactionId) { $order Order::where(order_sn, $outTradeNo)-lockForUpdate()-first(); if (!$order || $order-status ! 0) { return; } PaymentLog::firstOrCreate( [transaction_id $transactionId], [order_sn $outTradeNo, amount $order-total_fee] ); $order-status 1; $order-pay_time time(); $order-save(); });5.2 把 scene 协议做成公共方法最后分享一个值得在项目初期就定好的设计把所有业务入口共用的 scene 解析收成一个公共方法。无论是扫码点餐的桌台码还是外卖骑手的配送签到码沿用同一套协议。这样后续增加“优惠券立减码”“分享返利码”只需要扩展业务类型字段。?php /** * scene 编码1_业务类型_商户ID_负载 * 业务类型1点餐 2外卖骑手签到 3优惠券 4分享 * param int $biz 业务类型 * param int $merchantId 商户ID * param string $payload 负载如桌台ID T_205、骑手ID R_1024 */ function encodeScene(int $biz, int $merchantId, string $payload): string { return implode(_, [1, $biz, $merchantId, $payload]); } function decodeScene(string $scene): array { $parts explode(_, $scene); if (count($parts) 4 || (int) $parts[0] ! 1) { throw new \InvalidArgumentException(非法的scene格式); } return [ version (int) $parts[0], biz (int) $parts[1], merchant_id (int) $parts[2], payload implode(_, array_slice($parts, 3)), ]; } // 点餐场景encodeScene(1, 10001, T_205) // 配送签到encodeScene(2, 10001, R_1024) $ctx decodeScene($request-input(scene)); if ($ctx[biz] 1) { // 走餐桌绑定逻辑 } elseif ($ctx[biz] 2) { // 走骑手签到逻辑 }这个方法放在app/Support/SceneCode.php商户后台打印桌码时调用encodeScene生成 scene小程序端携带场景值进来时用decodeScene还原上下文。注意 scene 总长不超过 32 个字符所以 payload 部分建议限制 12 个字符以内像T_205、R_1024这种长度完全够用超过这个长度可以考虑把完整数据落 Redisscene 里只放一个短随机串。扫码点餐系统的所有入口统一走这个协议之后你再去开发外卖小程序端、骑手端、营销端前端只负责传参PHP 侧一个方法通吃。本文还有配套的精品资源点击获取