二维码扫进来不知道用户从哪来:小程序场景值与渠道参数追踪实战
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 返回的永远是「本次进入」的入口信息,不受冷热启动影响。
整个归因链路长这样:
flowchart LR
A[用户扫线下二维码] --> B{二维码类型}
B -->|小程序码 withScene| C[scene 字段装渠道参数]
B -->|普通链接二维码| D[query 字段装渠道参数]
C --> E[客户端取 options]
D --> E
E --> F[解析并上报]
F --> G[服务端归因表落库]
G --> H[渠道报表]
三种二维码,三种取参方式
线下投放常见的码有三种,取参方式完全不同,这是整套追踪里最容易搞混的部分。
小程序码(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 里只做初始化,取入口统一放到独立函数
// 每次进入页面都调一次,别只挂在启动回调里
function reportEntry() {
// getEnterOptionsSync 拿到的永远是「本次进入」的 options
const opts = wx.getEnterOptionsSync();
const scene = opts.query && opts.query.scene ? decodeURIComponent(opts.query.scene) : "";
if (scene) {
// 小程序码的 scene 是我们自己拼的 k=v&k2=v2,URL 编码后塞进去的
// 解析前必须先 decodeURIComponent,否则连 & 号都是 %26
const params = parseScene(scene);
if (params.ch) {
// 渠道号存在才上报,减少垃圾数据
reportChannel(params.ch, opts.scene, "qrcode");
}
} else if (opts.query && opts.query.ch) {
// 普通链接二维码走的是 query 原样透传,不用再解析
reportChannel(opts.query.ch, opts.scene, "link");
} else {
// 没有 ch 参数的,记一个「未知入口」,别直接丢掉
// 后面排查数据缺失时,这个兜底记录帮过我们大忙
reportChannel("_none", opts.scene, "unknown");
}
}
// scene 解析器:按 & 拆键值对,比 decode 全串再 split 更稳
function parseScene(raw) {
const out = {};
// scene 里理论上不该有额外空白,trim 一下防御物料端手误
raw.split("&").forEach(function (kv) {
if (!kv) return;
const idx = kv.indexOf("=");
// 没有等号的片段直接忽略,避免 key 为 undefined
if (idx < 1) return;
out[kv.slice(0, idx)] = kv.slice(idx + 1);
});
return out;
}
有个细节必须强调:opts.query.scene 在部分入口下是已经被微信解码过的,你如果再 decode 一次,遇到参数值里本来就有 % 的场景会解出乱码。稳妥做法是先判断字符串里有没有 % 再决定要不要 decode,或者干脆约定渠道参数值只用字母数字,绕开整个编码问题。我们选了后者,省事。
编码代码:生成小程序码那一半
环境:Node.js 18,axios 调微信接口,access_token 走缓存中间件。
// 渠道号白名单,防止拼 scene 时被塞进脏字符
// 白名单比黑名单省心,正则一行搞定
const CH_PATTERN = /^[A-Za-z0-9]{3,8}$/;
// 拼小程序码的 scene,总长不能超 32 个可见字符
function buildScene(params) {
const pairs = [];
for (const k of Object.keys(params)) {
// 值做 encodeURIComponent,键名我们自己保证是纯字母
// 编码后长度可能变长,必须用编码后的长度去算总量
const v = encodeURIComponent(String(params[k]));
pairs.push(k + "=" + v);
}
const scene = pairs.join("&");
// 超长直接抛错,宁可在生成时失败,不要等印出去才发现截断
if (scene.length > 32) {
throw new Error("scene 超长: " + scene.length + " 字符,需压缩参数");
}
return scene;
}
async function makeQrcode(ch) {
// 渠道号先过白名单,这段校验在上线第二周拦下过一次脏数据
if (!CH_PATTERN.test(ch)) throw new Error("非法渠道号 " + ch);
const scene = buildScene({ ch: ch });
// page 必须是已发布的页面路径,不能带 / 开头,也不能带参数
const resp = await wxApi.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 | 当天是否新用户,报表要用 |
上报链路的时序:
sequenceDiagram
participant U as 用户
participant M as 小程序客户端
participant S as 归因服务
participant D as 数据库
U->>M: 扫描带参小程序码
M->>M: getEnterOptionsSync 取 scene 并解析
M->>S: POST /report 上报 ch 与场景值
S->>S: 校验渠道号白名单与去重
S->>D: 写入 entry_log 并更新渠道日汇总
S-->>M: 200 返回
两个设计取舍说一下。去重窗口我们定的是同一用户 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 个百分点,查了一下午才定位到。
还没解决的问题
坦白说有两个。一是 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
场景值 · 渠道追踪 · 小程序码 · 二维码 · 数据分析 · 归因