简介:这是一套可直接学习的扫码点餐微信小程序前端工程,面向小程序初学者与餐饮SaaS开发者,覆盖多人同步点餐、菜品列表、菜品详情、购物车、确认订单、订单成功、历史订单、人数选择等全套点餐流程。工程共69个文件、约1.3MB,以9个wxml页面结构、10个wxss样式、14个js逻辑、12个json配置为主体,同时配有21张png切图和说明文档,目录按pages、images、utils、components组织,结构清晰。当前已有5402人学习下载,说明其在小程序点餐案例中具备较高参考价值。源码包含从“扫码进入→选菜→加购→提交订单→支付成功”的完整闭环,并预置服务员身份判断、免支付直接下单打印小票的后端配合思路;购物车模块的加减、多用户同步点餐时是否显示点餐人头像等实现细节,也可作为二次开发与面试梳理的有用素材。
1. 扫码点餐小程序为什么不是把菜单做成 H5 就完事
扫码点餐的微信小程序,核心不在“点餐”,而在“扫码”之后把用户精准锁定到正确的门店和桌号。很多人拿现成 H5 套壳,丢了场景值,用户扫进来还要重新选门店,第一屏流失就很高。真正的扫码点餐要打通二维码生成、scene 解析、微信登录、手机号授权、支付回调和后端订单联动,链条不复杂,但每一步都有边界:scene 最长 32 个可见字符、支付金额不能信任前端、订单表必须做幂等。我做餐饮 SaaS 和外卖系统时踩过不少坑,这篇把能直接落地的方案写出来,适合正在自建或改造扫码点餐小程序的工程师参考。
2. 扫码点餐的入口链路:二维码 scene 设计、解析与参数兼容
2.1 场景码选择:普通二维码、小程序码还是带参链接
扫码点餐的入口一般是贴在桌上的台卡码,二维码类型决定了用户扫码后怎么进入小程序、参数放在哪里。微信小程序里常见的有三种:普通链接二维码、小程序码、由任意工具生成但包含小程序路径的普通二维码。它们的差异直接影响你后续的解析逻辑。
| 码类型 | 获取方式 | 是否带参 | 适用场景 |
|---|---|---|---|
| 普通链接二维码 | 小程序后台配置域名校验 | 可带完整 URL query | 已有官网或 H5,想兼容新老入口 |
| 小程序码 | wxacode.getUnlimited / 码中心 | scene 参数 | 桌贴、台卡,推荐 |
| 普通二维码内容为小程序路径 | 任意生成工具 | 路径不能带较长参数 | 不推荐,路径长度容易超限 |
小程序码我通常用的是wxacode.getUnlimited,这个接口需要服务端调用,拿到 access_token 后换取码图片。因为 scene 长度限制,扫码点餐的桌号、门店号不能直接往里塞太长,后台一般再加一层短码映射。例如生成时只把短码写进 scene,用户扫码后小程序拿短码调后端换真实门店和桌号,后续更换桌台也不需要重新贴码。
注意,getUnlimited接口生成的图片数量有限,不是无限次免费调用,超过一定量会有接口计费,所以不要让前端自己调。我在服务端会封装一个/api/qrcode接口,接收 shopId 和 tableNo,返回二维码图片 base64 或图片 URL,打印台卡时直接调用这个接口,避免客户端直接暴露小程序 secret。
2.2 scene 参数编解码:桌面扫码点餐的桌号怎么传
先约定 scene 的格式。我习惯用k1=v1&k2=v2这种 query 风格,但场景值里如果出现中文桌名(比如“包间A”),必须先用 encodeURIComponent 编码,微信端收到后再解。生成端和解析端必须保持同一套规则,否则会出现扫码进来只有 shopId、桌号为空的诡异现象。
下面这段代码放在扫码后进入的页面 onLoad 中,是扫码点餐小程序入口解析最常用的写法:
// pages/index/index.js Page({ onLoad(options) { // 从普通链接二维码进入,参数在 options.q const q = options.q || ''; if (q) { const urlParams = this.parseQuery(q); this.setData({ shopId: urlParams.shopId, tableNo: urlParams.tableNo }); this.loadMenu(urlParams.shopId); return; } // 从小程序码进入,参数集中在 options.scene const scene = decodeURIComponent(options.scene || ''); const sceneParams = this.parseQuery(scene); this.setData({ shopId: sceneParams.shopId, tableNo: sceneParams.tableNo }); this.loadMenu(sceneParams.shopId); }, parseQuery(source) { const result = {}; if (!source) return result; source.split('&').forEach(function (pair) { const arr = pair.split('='); if (arr.length === 2 && arr[0] && arr[1]) { result[arr[0]] = decodeURIComponent(arr[1]); } }); return result; }, });逻辑说明:先处理普通链接二维码的options.q,再处理小程序码的options.scene。这里decodeURIComponent写在传入之前,是因为微信在某些场景返回的 scene 已经被编码过一次,如果前端再解一次,参数值里的“%”会变乱。parseQuery 只处理单层k=v,不处理嵌套对象,这个格式对扫码点餐的短码场景足够用了。
如果你遇到Cannot read property 'split' of undefined,多半是 options.scene 在线上版本为空,而本地测试正常。原因可能是普通二维码和小程序码走了不同字段,测试时没有分别模拟。微信开发者工具的“编译模式”里可以添加启动参数,分别构造scene=shop%3D1001%26tableNo%3D28和q=https://...两种启动参数,能覆盖线上大部分入口情况。
2.2.1 scene 要不要加密
扫码点餐的桌号和门店号不是敏感数据,被用户改一下只会串桌,不会直接造成资金损失。所以 scene 加密不是必须的,但必须防止“越权改桌”,用户在自己的手机上改了桌号,订单归属就变了,这点由后端在下单时校验会话绑定的桌号解决。
常见做法是后端生成短码并维护映射表short_code -> shop_id + table_no。小程序拿到短码后,调用POST /api/decode,后端返回门店信息。这种方案把校验逻辑收敛到服务端,小程序端只做展示。如果门店的桌台编号发生调整,只需要改映射表,不需要重新印刷海报和桌贴。
2.3 扫码后进入页面的 onLoad 时序与空码防护
扫码进入时,onLoad 的 options 只有在“首次加载”时才有值。如果小程序已经驻留后台,用户用另一个码扫进来,会触发 onShow 而不是 onLoad,此时 options 是空的。扫码点餐最常见的 bug 就在这里:用户先在 A 门店扫码进入小程序,菜单加载好了,锁屏放桌上,另一个用户拿同一部手机扫 B 门店的码,小程序被拉起,但页面上还是 A 门店的菜单。
我一般会同时监听 onLoad 和 onShow,并保存一个“启动场景值”到全局变量,避免重复逻辑。
// app.js 中定义全局变量 globalData = { launchScene: '' }; // page/index/index.js Page({ onLoad(options) { if (options.scene) { const scene = decodeURIComponent(options.scene); app.globalData.launchScene = scene; this.processScene(scene); } }, onShow() { const scene = app.globalData.launchScene; if (scene && !this.initialized) { this.processScene(scene); this.initialized = true; } }, });代码说明:第一次 onLoad 处理完场景后设置 initialized 标记,避免 onShow 再次请求菜单。线下桌贴场景每次扫码基本都会重新冷启动,所以问题不明显;但预约到店、外卖自取等入口共用小程序时,就必须考虑这个时序。空码防护是指当 scene 没解析出 shopId 时,页面不能直接跳转到错误页,要允许用户手动选择门店,否则扫码回来蒙在黑屏里。
3. 扫码点餐菜单渲染与购物车状态管理:从列表数据到结算单
3.1 菜单与菜品规格的数据结构约定
扫码点餐的菜单不是简单一个数组。门店下的菜品要分组、要支持规格和口味、还可能区分营业时段。后端接口我会按这个结构返回:
{ "code": 0, "data": { "shop_id": 1001, "table_no": "28", "categories": [ { "id": 10, "name": "招牌推荐", "items": [ { "item_id": 1001, "name": "现切毛肚", "price": 68, "unit": "份", "image": "https://cdn.example.com/a.png", "specs": [ { "spec_id": 1, "name": "大份", "price": 88 }, { "spec_id": 2, "name": "小份", "price": 48 } ], "stock": 20 } ] } ] } }要点是价格字段放在两个层级:基础price用于列表页展示“起价”,实际结算取用户所选spec.price。这样门店可以设置“毛肚 48 元起”,用户点进详情选完规格再看到最终价格,减少价格错乱。如果菜品没有规格,后端统一返回空数组,小程序端根据specs.length判断显示单价格还是规格选择。
后端接口如果一次把分类和菜品全部返回,菜单条数少时体验很好。一旦菜品超过 200,首屏图片和渲染都会明显变慢。我一般会把接口拆成“分类列表”和“分类下菜品详情”两级,但扫码点餐的流程里不推荐强制用户先点分类,而是用左侧分类栏或 tab 切换,避免多一次请求。图片地址要压缩,微信小程序里通常用 CDN 的图片缩放参数,原图会让页面滚动掉帧。
3.2 购物车操作状态与 setData 性能
微信小程序页面渲染依赖 setData,而加购是高频操作:每点一次“加购”都会触发 data 更新。如果购物车用数组存储,每次 push 后 setData,数组长度一长,diff 成本高,页面滚动时会有明显闪烁。我的方案是购物车用一个对象 map 存储,key 是“菜品ID_规格ID”,value 是{ count, price, name },加购时不需要遍历数组,只需要改对象的一个属性。
// pages/cart/cart.js Page({ data: { cartMap: {} }, addItem(event) { const item = event.currentTarget.dataset.item; const key = item.item_id + '_' + (item.spec_id || 'default'); const cartMap = Object.assign({}, this.data.cartMap); if (cartMap[key]) { cartMap[key].count += 1; } else { cartMap[key] = { item_id: item.item_id, spec_id: item.spec_id || 'default', name: item.name, price: Number(item.price), count: 1 }; } this.setData({ cartMap }); wx.setStorageSync('scan_cart_' + this.data.shopId, cartMap); }, getCartArray() { return Object.keys(this.data.cartMap).map(key => this.data.cartMap[key]); } });参数说明:event.currentTarget.dataset.item是 WXML 里通过>// order_create.php 伪代码 $json = json_decode(file_get_contents('php://input'), true); $requestId = trim($json['request_id'] ?? ''); $shopId = intval($json['shop_id'] ?? 0); $openid = $json['openid'] ?? ''; $amount = round(floatval($json['amount'] ?? 0), 2); $items = $json['items'] ?? []; if (!$requestId || !$shopId || !$openid || count($items) == 0) { echo json_encode(['code' => 400, 'msg' => '参数不完整']); exit; } $pdo->beginTransaction(); try { $stmt = $pdo->prepare( "INSERT INTO orders (request_id, openid, shop_id, amount, status, create_time) VALUES (?, ?, ?, ?, 0, NOW())" ); $stmt->execute([$requestId, $openid, $shopId, $amount]); $orderId = $pdo->lastInsertId(); foreach ($items as $item) { $stmt = $pdo->prepare( "INSERT INTO order_items (order_id, item_id, spec_id, price, count) VALUES (?, ?, ?, ?, ?)" ); $stmt->execute([ $orderId, intval($item['item_id']), intval($item['spec_id']), round(floatval($item['price']), 2), intval($item['count']) ]); } $pdo->commit(); echo json_encode(['code' => 0, 'order_id' => $orderId]); } catch (Exception $e) { $pdo->rollBack(); echo json_encode(['code' => 0, 'msg' => '订单已存在']); }
这里几个参数要说清楚:request_id要包含 openid 一起查,因为不同用户可能生成相同 UUID 的概率极低,锁定在用户维度更安全。amount字段后端不能只信前端传入值,真正下单时应该根据 order_items 重新计算,但这里为了演示保留了前端传值,生产环境请忽略amount,直接遍历 items 从菜品价格表里读。如果订单表已经建了unique(request_id, openid),捕获到唯一索引冲突时直接返回重复提交即可,不需要额外查一次。
幂等之外还要考虑库存。扫码点餐热门菜如果只剩最后一份,两个用户同时下单,后端需要在事务里对 item_id 加行锁,或者用条件更新update dishes set stock_count = stock_count - 1 where id = ? and stock_count > 0。PHP 里可以用SELECT ... FOR UPDATE,更简洁的方式是在插入订单明细前逐条扣减库存,扣减失败就回滚。
4. 扫码点餐的登录、手机号与支付闭环:wx.login 到支付回调
4.1 微信小程序登录态刷新与会话有效期
扫码点餐如果强制用户一进来就授权登录,流失率很高。我一般允许游客先加购,到结账时再触发登录。微信小程序登录的官方流程是用wx.login()拿 code,后端拿 code 调code2Session接口换取 openid 和 session_key。code 有效期为 5 分钟,且只能使用一次,后端拿到后必须立刻消费。
function wxLogin() { return new Promise((resolve, reject) => { wx.login({ success(res) { if (res.code) { resolve(res.code); } else { reject(res.errMsg); } }, fail: reject }); }); } async function bindLogin() { const code = await wxLogin(); const resp = await request({ url: '/api/login', method: 'POST', data: { code } }); if (resp.code === 0) { wx.setStorageSync('token', resp.data.token); wx.setStorageSync('openid', resp.data.openid); } }参数说明:code2Session返回的 openid 是该用户在当前小程序下的唯一标识,session_key 用于解密手机号等隐私数据。不要把 session_key 返回给前端,有泄漏风险。后端应该记录openid与自建 token 的绑定关系,token 过期后重新登录,用户无感知。
在扫码点餐场景,用户可能在支付过程中切走微信,再回来时 token 已过期。我在页面 onShow 里会做一次静默登录,碰到 401 再触发 wx.login,而不是每次都重新调 code2Session。微信对这个接口有频率限制,大量用户同时扫码进店时,频繁刷新可能触发errcode 45011的接口限流,需要在服务端加缓存,同一个 openid 的 token 没过期就不要重复换。
4.1.1 服务端换取 openid 的坑
PHP 后端换 openid 常见写法如下:
$appid = 'wx123456'; $secret = getenv('WX_SECRET'); $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $resp = file_get_contents($url); $data = json_decode($resp, true); if (!isset($data['openid'])) { // 记录日志,返回统一错误码给前端 }注意:file_get_contents在 PHP 默认配置下不支持超时控制,生产环境建议用 curl 并设置CURLOPT_TIMEOUT为 3 秒。扫码点餐高峰时如果微信接口抖动,登录接口会跟着超时,一定要做降级:允许游客继续浏览菜单,等用户结算时再触发登录。secret 必须放在环境变量或只读配置里,不能出现在 JS 或 PHP 源码中,否则被人抓包拿到以后可以任意换取用户信息。
4.2 手机号快速验证与隐私授权
商家要求顾客留手机号,一般是用于会员积分和取餐通知。微信现在用button open-type="getPhoneNumber"获取手机号,前端拿到的不是明文,而是一个动态令牌 code,后端拿这个 code 调微信接口换取手机号。新版接口phonenumber.getPhoneNumber不再需要 session_key 解密,而是直接通过 code 换,个人主体小程序无法使用该能力。
<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber"> 微信一键登录并同意会员协议 </button>代码里bindgetphonenumber回调收到的 event.detail.code,需要在支付前传给后端,后端调微信接口换手机号。如果用户拒绝授权,你可以在页面上提供手动输入手机号的入口,而不是强制弹窗,扫码点餐的体验会好很多。还要注意隐私保护指引里必须写明收集手机号的目的,否则审核会被拒。
4.3 支付金额计算与订单快照
支付环节最容易出问题的不是请求代码,而是“前端算金额,下单把钱传来”的方式。正确姿势是小程序端只传菜品 ID、规格 ID、数量,后端从数据库读实时价格,算出合计金额,再调统一下单接口。优惠券、会员折扣也必须在后端计算,前端只展示结果。
统一下单成功后,后端拿到prepay_id,按商户平台要求生成签名,返回给小程序端。小程序端调用wx.requestPayment:
wx.requestPayment({ timeStamp: pay.timeStamp, // 支付签名时间戳 nonceStr: pay.nonceStr, // 随机字符串 package: pay.package, // 格式为 prepay_id=xxx signType: 'RSA', // 要和商户配置一致 paySign: pay.paySign, success() { wx.redirectTo({ url: '/pages/order/detail?orderId=' + pay.orderId }); }, fail(err) { if (err.errMsg && err.errMsg.indexOf('cancel') > -1) { return; } wx.showToast({ title: '支付失败,请重试', icon: 'none' }); } });代码说明:signType在小程序支付中默认是MD5,如果你的商户平台配置的是 RSA,这个字段必须同步改,否则报sign error。很多扫码点餐项目从别人源码拷贝过来,signType 没跟着后台配置改,前端报错后第一反应是查签名算法,实际上只是参数没对齐。package参数必须是prepay_id=xxxx完整字符串,缺少等号会导致拉起支付失败。
支付成功的异步回调需要单独关注。wx.requestPayment的 success 只代表微信支付成功,微信服务器之后会向商户后台发支付结果回调,商户后台需要修改订单状态。商家接单端依赖这个回调推送,而不是前端跳转。如果回调 URL 没有配置好,会出现用户已付款但商家看不到订单的情况。我习惯把订单状态设为:0 待支付、1 已支付待接单、2 接单制作中、3 已完成、4 已退款,并写一个定时任务自动关闭超时未支付的订单。
5. 用 Charles 抓包检查扫码点餐小程序:参数错误和状态异常的定位方法
扫码点餐上线后如果出现“扫码进来是别人的门店”“点了菜没生成订单”“支付成功订单没变”这类问题,最快定位方式是抓包。Charles 抓包微信小程序,要先把电脑和手机连到同一局域网,手机设置 HTTP 代理,并安装信任 Charles 的 CA 证书,再开启 SSL Proxying 并把 api 域名加入白名单。抓包能看到三块关键链路:登录换取 openid、菜单接口返回、下单支付请求。
| 异常现象 | 抓包重点检查的位置 | 常见结论 |
|---|---|---|
| 扫码进去菜单不对 | 入口页请求的 query 是否带 shopId | scene 解析失败或 q 和 scene 用混 |
| 点了菜没有生成订单 | POST /order/create 的 body | request_id 重复被幂等拦截,或 items 为空 |
| 支付成功订单不变 | 微信回调 /notify 是否接收成功 | 回调地址未配置或签名验证失败 |
| 个人主体无法授权手机号 | 授权接口返回 errcode | 没有企业认证不支持该能力 |
抓包时重点看请求头里的token和 body 里的request_id、openid,三个值要对应同一个用户。多用户同时测试时,后端日志里通过 request_id 关联订单,比在小程序端猜代码更快。二维码参数解析错误在 Charles 里看入口接口的完整 URL 就能定位:如果显示scene=shop%3D1001%26tableNo%3D28,说明前端少做了一次 decodeURIComponent,如果显示完整的q=https://...但 shopId 为空,说明普通链接二维码的 query 解析逻辑没有生效。
我还会在入口页加一个只有测试环境可见的调试条,显示当前 shopId、tableNo、openid 前四位,线下门店反馈问题时截图给开发,省去抓包和证书安装的沟通成本。线上版本不要开启这个调试条,否则会被运营误认为线上缺陷。扫码点餐的链路长,只要守住入口参数、登录态、幂等键、支付回调四个位置,八成线上问题都能自己定位。
本文还有配套的精品资源,点击获取