
简介这是一套可直接学习的扫码点餐微信小程序前端工程面向小程序初学者与餐饮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 的格式。我习惯用k1v1k2v2这种 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 只处理单层kv不处理嵌套对象这个格式对扫码点餐的短码场景足够用了。如果你遇到Cannot read property split of undefined多半是 options.scene 在线上版本为空而本地测试正常。原因可能是普通二维码和小程序码走了不同字段测试时没有分别模拟。微信开发者工具的“编译模式”里可以添加启动参数分别构造sceneshop%3D1001%26tableNo%3D28和qhttps://...两种启动参数能覆盖线上大部分入口情况。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_typeauthorization_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-typegetPhoneNumber获取手机号前端拿到的不是明文而是一个动态令牌 code后端拿这个 code 调微信接口换取手机号。新版接口phonenumber.getPhoneNumber不再需要 session_key 解密而是直接通过 code 换个人主体小程序无法使用该能力。button open-typegetPhoneNumber bindgetphonenumberonGetPhoneNumber 微信一键登录并同意会员协议 /button代码里bindgetphonenumber回调收到的 event.detail.code需要在支付前传给后端后端调微信接口换手机号。如果用户拒绝授权你可以在页面上提供手动输入手机号的入口而不是强制弹窗扫码点餐的体验会好很多。还要注意隐私保护指引里必须写明收集手机号的目的否则审核会被拒。4.3 支付金额计算与订单快照支付环节最容易出问题的不是请求代码而是“前端算金额下单把钱传来”的方式。正确姿势是小程序端只传菜品 ID、规格 ID、数量后端从数据库读实时价格算出合计金额再调统一下单接口。优惠券、会员折扣也必须在后端计算前端只展示结果。统一下单成功后后端拿到prepay_id按商户平台要求生成签名返回给小程序端。小程序端调用wx.requestPaymentwx.requestPayment({ timeStamp: pay.timeStamp, // 支付签名时间戳 nonceStr: pay.nonceStr, // 随机字符串 package: pay.package, // 格式为 prepay_idxxx 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_idxxxx完整字符串缺少等号会导致拉起支付失败。支付成功的异步回调需要单独关注。wx.requestPayment的 success 只代表微信支付成功微信服务器之后会向商户后台发支付结果回调商户后台需要修改订单状态。商家接单端依赖这个回调推送而不是前端跳转。如果回调 URL 没有配置好会出现用户已付款但商家看不到订单的情况。我习惯把订单状态设为0 待支付、1 已支付待接单、2 接单制作中、3 已完成、4 已退款并写一个定时任务自动关闭超时未支付的订单。5. 用 Charles 抓包检查扫码点餐小程序参数错误和状态异常的定位方法扫码点餐上线后如果出现“扫码进来是别人的门店”“点了菜没生成订单”“支付成功订单没变”这类问题最快定位方式是抓包。Charles 抓包微信小程序要先把电脑和手机连到同一局域网手机设置 HTTP 代理并安装信任 Charles 的 CA 证书再开启 SSL Proxying 并把 api 域名加入白名单。抓包能看到三块关键链路登录换取 openid、菜单接口返回、下单支付请求。异常现象抓包重点检查的位置常见结论扫码进去菜单不对入口页请求的 query 是否带 shopIdscene 解析失败或 q 和 scene 用混点了菜没有生成订单POST /order/create 的 bodyrequest_id 重复被幂等拦截或 items 为空支付成功订单不变微信回调 /notify 是否接收成功回调地址未配置或签名验证失败个人主体无法授权手机号授权接口返回 errcode没有企业认证不支持该能力抓包时重点看请求头里的token和 body 里的request_id、openid三个值要对应同一个用户。多用户同时测试时后端日志里通过 request_id 关联订单比在小程序端猜代码更快。二维码参数解析错误在 Charles 里看入口接口的完整 URL 就能定位如果显示sceneshop%3D1001%26tableNo%3D28说明前端少做了一次 decodeURIComponent如果显示完整的qhttps://...但 shopId 为空说明普通链接二维码的 query 解析逻辑没有生效。我还会在入口页加一个只有测试环境可见的调试条显示当前 shopId、tableNo、openid 前四位线下门店反馈问题时截图给开发省去抓包和证书安装的沟通成本。线上版本不要开启这个调试条否则会被运营误认为线上缺陷。扫码点餐的链路长只要守住入口参数、登录态、幂等键、支付回调四个位置八成线上问题都能自己定位。本文还有配套的精品资源点击获取