二维码扫进来不知道用户从哪来:小程序场景值与渠道参数追踪实战
适用读者:做过带参二维码投放、被运营追着问「这批扫码用户到底从哪个渠道来的」的小程序开发者;正在设计渠道归因表的后端;以及所有被
scene参数坑过的同行。
TL;DR
- scene 与 query 是两套独立参数,归因必须同时记录。
- 小程序码 scene 有 32 字符编码后长度限制,生成端必须校验编码后长度。
- 统一用
wx.getEnterOptionsSync()取入口信息,避免 onLaunch 漏记热启动。- 归因表将入口大类与渠道明细拆分为 scene_type 和 channel_code 两个字段。
9 月 3 日晚上十点半,运营的小蒋在群里甩过来一句:「传单印了两万张,线上核销只有三百多,扫码用户你们到底有没有记渠道?」我翻了一圈数据库,答不上来——因为我们只记了scene的原始值,而三种二维码的 scene 拼法不一样,数据早搅成了一锅粥。那天之后我花了两周,把公司的渠道追踪从零到一补齐,踩的坑比想象中多得多。这篇文章把这些坑原样摊开,包括两个到现在还没彻底解决的问题。
场景值机制:小程序凭什么知道用户从哪来
先说底层。微信小程序的启动参数是一套「入口描述」体系:用户从任何入口(扫码、搜一搜、卡片、好友分享)进小程序,微信客户端都会在启动时把入口信息打包成一份options交给开发者。这份options里有三个关键字段:
| 字段 | 含义 | 举例 |
|---|---|---|
| scene | 场景值,固定枚举 | 1047(扫小程序码)、1011(扫二维码)、1035(公众号菜单) |
| query | 启动参数,键值对 | ch=dt01&sid=88 |
| path | 启动页面路径 | pages/index/index |
场景值(scene)和启动参数(query)是两码事,很多新手第一次都混了。scene 是微信定义的枚举数字,回答「用户用什么方式进来的」;query 是你自己塞进去的参数,回答「具体是哪张码」。归因必须两个都看:scene 用来分大类(扫线下物料、扫商品码、分享卡片),query 里的渠道参数用来落到具体的某一批投放。
这份 options 在生命周期里出现两次:App.onLaunch和App.onShow各给一次,冷启动和热切换的值会不同。用户在微信里把小程序切到后台、逛了一圈再切回来,onShow会拿到一份新的 options。我们第一版就栽在这里——只在onLaunch记了一次,结果每天有大约 12% 的访问记录不到入口。后来才换成wx.getEnterOptionsSync(),这个 API 返回的永远是「本次进入」的入口信息,不受冷热启动影响。
整个归因链路长这样:
三种二维码,三种取参方式
线下投放常见的码有三种,取参方式完全不同,这是整套追踪里最容易搞混的部分。
小程序码(withScene)。走wxacode.getUnlimited接口生成,参数通过scene字段携带,进小程序后出现在 options 的query.scene里,是 URL 编码过的字符串。限制:scene 最大 32 个可见字符,只支持数字、大小写英文以及!#$&'()*+,/:;=?@-._~这几个符号。中文直接传不了,等号拼接要自己解析。
普通带参二维码。你自己生成一个指向已配置域名 H5 的二维码,用户扫了以后经由微信的「扫普通链接二维码打开小程序」能力跳进小程序,原始 URL 的查询串会进query,不用编码也不用截断,能装的东西多得多。
公众号/文章里的码。这种要靠 scene 值区分入口大类,渠道参数同样走自己的 query。
三种方式的对比:
| 维度 | 小程序码 scene | 普通链接二维码 query | 分享卡片 |
|---|---|---|---|
| 容量 | 32 字符上限 | 受 URL 长度限制,很宽松 | 可自定义 path 和参数 |
| 编码 | 必须 URL 编码 + 自定义解析 | 原样透传 | 直接是对象 |
| 适合 | 线下物料、批量化投放 | 已有 H5 体系的项目 | 线上裂变 |
| 生成方式 | 服务端调微信接口 | 任意二维码库 | 前端拼 path |
我们的传单投放最后选了小程序码,一版物料印了 7 个渠道,每个渠道 5 万张,scene里只装了一个ch参数加三位渠道号。
解析代码:客户端这一半
环境:微信小程序基础库 2.21+,原生框架,TypeScript 也行这里用 JS 方便看。
// App.onLaunch 里只做初始化,取入口统一放到独立函数// 每次进入页面都调一次,别只挂在启动回调里functionreportEntry(){// getEnterOptionsSync 拿到的永远是「本次进入」的 optionsconstopts=wx.getEnterOptionsSync();constscene=opts.query&&opts.query.scene?decodeURIComponent(opts.query.scene):"";if(scene){// 小程序码的 scene 是我们自己拼的 k=v&k2=v2,URL 编码后塞进去的// 解析前必须先 decodeURIComponent,否则连 & 号都是 %26constparams=parseScene(scene);if(params.ch){// 渠道号存在才上报,减少垃圾数据reportChannel(params.ch,opts.scene,"qrcode");}}elseif(opts.query&&opts.query.ch){// 普通链接二维码走的是 query 原样透传,不用再解析reportChannel(opts.query.ch,opts.scene,"link");}else{// 没有 ch 参数的,记一个「未知入口」,别直接丢掉// 后面排查数据缺失时,这个兜底记录帮过我们大忙reportChannel("_none",opts.scene,"unknown");}}// scene 解析器:按 & 拆键值对,比 decode 全串再 split 更稳functionparseScene(raw){constout={};// scene 里理论上不该有额外空白,trim 一下防御物料端手误raw.split("&").forEach(function(kv){if(!kv)return;constidx=kv.indexOf("=");// 没有等号的片段直接忽略,避免 key 为 undefinedif(idx<1)return;out[kv.slice(0,idx)]=kv.slice(idx+1);});returnout;}有个细节必须强调:opts.query.scene在部分入口下是已经被微信解码过的,你如果再 decode 一次,遇到参数值里本来就有%的场景会解出乱码。稳妥做法是先判断字符串里有没有%再决定要不要 decode,或者干脆约定渠道参数值只用字母数字,绕开整个编码问题。我们选了后者,省事。
编码代码:生成小程序码那一半
环境:Node.js 18,axios调微信接口,access_token 走缓存中间件。
// 渠道号白名单,防止拼 scene 时被塞进脏字符// 白名单比黑名单省心,正则一行搞定constCH_PATTERN=/^[A-Za-z0-9]{3,8}$/;// 拼小程序码的 scene,总长不能超 32 个可见字符functionbuildScene(params){constpairs=[];for(constkofObject.keys(params)){// 值做 encodeURIComponent,键名我们自己保证是纯字母// 编码后长度可能变长,必须用编码后的长度去算总量constv=encodeURIComponent(String(params[k]));pairs.push(k+"="+v);}constscene=pairs.join("&");// 超长直接抛错,宁可在生成时失败,不要等印出去才发现截断if(scene.length>32){thrownewError("scene 超长: "+scene.length+" 字符,需压缩参数");}returnscene;}asyncfunctionmakeQrcode(ch){// 渠道号先过白名单,这段校验在上线第二周拦下过一次脏数据if(!CH_PATTERN.test(ch))thrownewError("非法渠道号 "+ch);constscene=buildScene({ch:ch});// page 必须是已发布的页面路径,不能带 / 开头,也不能带参数constresp=awaitwxApi.post("/wxa/getwxacodeunlimit",{scene:scene,page:"pages/index/index",check_path:false,});// 返回的是图片二进制流,落 OSS 后把 ch 存进文件名,方便对账return{buffer:resp.data,key:"qr/"+ch+"/"+Date.now()+".png"};}8 月 20 日那次翻车值得记一笔。当时有个渠道想带活动编号,ch=dt01&act=zhuanti0901拼出来 27 个字符,看着没超,但act的值里有中文,encodeURIComponent之后膨胀到 40 多个字符。接口当时没做长度断言,微信直接返回了错误码,打包脚本卡了两小时。所以上面代码里那个超长抛错,是拿一次真金白银的加班换来的。
渠道归因表怎么设计
服务端这边一张表就够了,关键是把「入口大类」和「渠道明细」拆成两个字段,报表才好做。
| 字段 | 类型 | 说明 |
|---|---|---|
| open_id_hash | varchar(64) | open_id 做哈希后存,避免敏感信息直存 |
| channel_code | varchar(16) | query 里的 ch 参数,归因的主键 |
| scene_type | int | 微信场景值枚举,1047/1011/1035 等 |
| entry_type | varchar(8) | qrcode/link/share/unknown 四类 |
| entered_at | datetime | 进入时间,建索引 |
| is_new_user | tinyint | 当天是否新用户,报表要用 |
上报链路的时序:
两个设计取舍说一下。去重窗口我们定的是同一用户 30 分钟内重复上报只记一次,这个数字是拿 9 月上旬三天的日志回放试出来的——太短会把「扫码进店→退出→再进」记成两次,太长又会漏掉真实的多次访问。另一个是channel_code建了唯一索引,配合渠道字典表做外键校验,字典里不存在的渠道号进库时会被标脏,第二天人工核对,而不是直接拒掉。
上线两周的实测数据
以下均为我们自建监测口径(entry_log 表统计,样本约 3.1 万次进入),不代表任何第三方平台数据。
上线后的报表大概是这样:
| 渠道 | 扫码进入 | 新用户占比 | 次日留存 |
|---|---|---|---|
| 传单 dt01 | 4120 | 61% | 18% |
| 店内立牌 dp02 | 1870 | 34% | 26% |
| 异业合作 yh03 | 940 | 72% | 12% |
三个数字推翻了运营原来的两个假设:传单量大但留存垫底的是异业合作那批(用户是冲合作方奖品来的),店内立牌量不大留存反而不错。9 月 18 日的运营会上,小蒋原话是「早半年有这张表,上季度的预算就不会那么分了」。追踪这件事的价值不在技术,在于让投放决策有依据。
踩坑清单,全是眼泪
scene 的 32 位是「编码后」的长度。中文、=、&经encodeURIComponent后一个字符能膨胀到 9 个字节。生成端必须拿编码后的串算长度,而不是拼完的原始参数。
onLaunch里的 options 不是万能的。热启动场景下用户可能从别的入口再次进入,只记 onLaunch 会漏。统一用wx.getEnterOptionsSync(),并且在onShow里也调一次、按时间戳取更新的那份。
小程序码和普通二维码的 scene 值不同。扫小程序码场景值是 1047,扫普通链接二维码跳小程序是 1011,报表上如果只按渠道号分组不区分类别,两类数据会缠在一起,排查起来非常费劲。
check_path: false的坑。调试期用check_path: false生成码,指向的页面还没发布,用户扫码会提示页面不存在。我们 8 月 28 日放过一批测试码到门店,被店长拍照发群里质疑「码是假的」。发布页面之前,任何码都不要流出。
测试码和正式码要分渠道号段。我们划了test前缀做测试段,报表 SQL 里直接过滤。有一次测试数据混进正式报表,把当周新用户占比拉高了 9 个百分点,查了一下午才定位到。
排查实战:一次渠道数据对不上的完整定位过程
光有归因表还不够,数据对不上时怎么查,才是这套系统真正值钱的地方。分享一次真实的排查过程,正好把前面踩的坑串起来。
某天上午十点,运营小蒋又来了:「传单渠道今天进入量怎么比昨天同期少了 40%?」我第一反应是别慌,先分三步走。
第一步:先看上报曲线,定位异常时间点。直接查 entry_log 表,按小时分组看传单渠道的进入量,对比前三天同时段。
SELECTDATE_FORMAT(entered_at,'%Y-%m-%d %H:00')AShour_bucket,COUNT(*)ASenter_cntFROMentry_logWHEREchannel_code='dt01'ANDentered_at>=DATE_SUB(NOW(),INTERVAL3DAY)GROUPBYhour_bucketORDERBYhour_bucket;跑出来发现,异常不是从零点开始的,而是从当天早上 7 点整突然断崖式下跌,前一天同时段每小时还有 300 左右,今天直接掉到 180。时间点很干净,说明不是全天性的投放问题,更像某个时刻之后上报链路出了问题。
第二步:对比微信公众平台「访问分析」,判断是上报缺失还是真实下降。打开公众平台的「统计 → 访问分析」,看「扫小程序码」这个场景的实时趋势。如果微信侧也同步下跌,那是真实流量少了;如果微信侧正常、只有我们报表跌,那就是上报丢了。
当时对比下来,微信侧「扫小程序码」的实时曲线是平稳的,只有我们 entry_log 在跌。结论明确:流量没少,是上报丢了,问题出在我们自己这一侧。
第三步:检查当天是否有代码发布,重点看 getEnterOptionsSync 的调用时机。翻发布记录,当天凌晨 1 点确实上线了一个版本,改动里恰好动了入口上报逻辑。回看代码,问题出在热启动场景:
// 有问题的写法:只在 onLaunch 里取一次App({onLaunch(){// 冷启动时能拿到 query,但热启动时这里可能是旧的或空的this.globalData.entry=wx.getEnterOptionsSync();},});// 修正后的写法:每次 onShow 都重新取,按时间戳取最新App({onShow(){constopts=wx.getEnterOptionsSync();// 热启动时基础库可能返回空 query,必须做兜底constscene=opts.query&&opts.query.scene?decodeURIComponent(opts.query.scene):"";if(scene){reportChannel(parseScene(scene).ch,opts.scene,"qrcode");}else{// 空 query 一律走 _none 兜底,别静默丢弃reportChannel("_none",opts.scene,"unknown");}},});第四步:定位根因——某版本基础库热启动返回空 query。光改代码还不够,得搞清楚为什么之前没暴露。查了微信基础库的更新日志和社区帖子,发现某个版本的基础库在热启动(小程序从后台切回前台)时,getEnterOptionsSync()返回的query是空的,只有scene有值。而我们新版代码恰好把「取入口」从onShow挪到了onLaunch只取一次,冷启动没问题,热启动就全走了_none兜底分支——报表里传单渠道的_none占比从平时的 3% 飙到了 43%。
-- 验证:看异常时段 _none 兜底占比是否异常升高SELECTDATE_FORMAT(entered_at,'%Y-%m-%d %H:00')AShour_bucket,entry_type,COUNT(*)ASenter_cntFROMentry_logWHEREchannel_code='dt01'ANDentered_at>='2026-09-24 06:00:00'ANDentered_at<'2026-09-24 10:00:00'GROUPBYhour_bucket,entry_typeORDERBYhour_bucket,entry_type;结果_none占比确实异常。修复方案是双保险:一是onShow里每次重新取getEnterOptionsSync(),二是对空query但scene有值的情况,尝试用scene反查渠道(虽然拿不到ch参数,但至少能归到「扫小程序码」这个大类,不至于全丢)。上线后观察两天,传单渠道上报量恢复正常,_none占比回落到 3% 以内。
这次排查最大的收获是:数据对不上时,先分「上报缺失」和「真实下降」,再查代码变更,最后落到基础库行为,顺序不能乱。而_none兜底记录,就是为这种时刻准备的——没有它,你连「丢了多少」都说不清。
还没解决的问题
坦白说有两个。一是 iOS 微信部分版本下,从相册识别二维码进入时偶发 scene 解析出空串,复现率大概千分之三,报了微信开放社区没人回,目前只能靠_none兜底记录观察。二是普通链接二维码依赖「已配置的域名跳转规则」,改一次规则要全量重发物料,这对线下投放太不友好了,还没想到更好的办法。这两个坑如果有同行踩过并有解法,评论区求指点。
参考与延伸
- wx.getEnterOptionsSync 官方文档:https://developers.weixin.qq.com/miniprogram/dev/api/base/app/wx.getEnterOptionsSync.html
- 小程序码 getUnlimited 接口文档(含 scene 限制说明):https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/qrcode-link/qr-code/getUnlimitedQRCode.html
- 场景值枚举清单:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/scene.html
- 扫普通链接二维码打开小程序配置指引:https://developers.weixin.qq.com/miniprogram/introduction/qrcode.html
场景值 · 渠道追踪 · 小程序码 · 二维码 · 数据分析 · 归因