《炊想》是个小程序:说一道菜,它把一桌饭配好。10 月 5 日上线,除了自己和测试号,只有上线当天有 2 个人注册。到 10 月 9 日盘点数据时发现:另外三十来个陌生访客打开过它,没有一个人用上搭配,10 月 5 日之后也没有一个人再申请注册验证码。
最可疑的,是核心功能前面横着的那道邮箱注册。10 月 10 日上线的 2.0.16 把这道墙拆了:打开就能用,想留下来再绑定邮箱;分享链接带上邀请码,新朋友进来双方各得 1 次;再补上埋点,以后每一步流失多少一眼看清。
这篇记录几处设计:没有埋点时怎么从 nginx 日志里看访客、看不到什么,游客账号为什么不另建一张表,绑定邮箱为什么是「原地转正」,原来的防刷计数为什么要跟着改口径,以及邀请奖励和埋点的取舍。
一、没有埋点,先翻 nginx 日志
2.0.16 之前小程序没有任何埋点,库里只记得注册、登录、搭配过的人,只看看就走的访客一个都没留下。好在小程序发出的请求都会进 nginx 访问日志,Referer 是固定格式:
https://servicewechat.com/{appid}/{version}/page-frame.html
按微信的文档,{version} 为 0 是开发版、体验版和审核版,为 devtools 是开发者工具,其余是正式版。把访问日志按这一段分开,就能只看正式版的访问;再把已经注册、用过的老用户的访问剔掉,剩下的就是陌生访客。
翻出来的结果:
- 陌生 IP 三十来个,集中在 10 月 5 日晚上(发朋友圈和博客那一波),之后每天四到六个,10 月 9 日到盘点时(下午)还一个都没有;
- 路径几乎一样:首页拉一下推荐菜,最多点开一道菜的做法,离开;
- 有 4 个人在搜索框里打过字(输入时会请求联想词,所以日志里看得到),之后就再没有请求;
- 验证码表里,10 月 5 日之后一条注册验证码都没有。
按 IP 算只能看个大概:同一个人换网络会算成两个,共用出口 IP 的几个人又会算成一个。日志还有一个盲区:没登录时点「开始搭配」,只会跳到本地的登录页,不发任何请求,所以看不出有多少人点过、是不是在登录页走的。
能确定的是:没有一个人走到申请验证码那一步。而想用上核心功能,必须先去邮箱收验证码、再设用户名和密码,在微信里大家习惯的是点一下就能用。这道墙是最可疑的一环,但「首页没讲清楚能干什么」也解释得通。所以这次两件事一起做:先拆墙,再补埋点,下次就能分清人停在了哪一步。
二、方案:先让人用上,再让人留下
微信的手机号快速验证只对非个人主体开放,炊想是个人主体,用不了(何况还按次收费);wx.login 不弹授权框、不收费,换来的 openid 足够认出「这是同一个微信」。于是这一版做了四件事:
| 改动 | 解决什么 |
|---|---|
| 打开就用 openid 静默开一个游客,首页直接能搭 | 门槛:不填邮箱、不设密码 |
| 游客绑定邮箱时原地转正 | 留存:想留下来的再绑定邮箱,次数和记录都保留 |
| 分享路径带邀请码,新朋友进来双方各得 1 次 | 入口:给每个用户一个转发的理由 |
| 埋点 + 后台「拉新漏斗」 | 度量:以后每一步流失多少看得见,不用再猜 |
![]()
三、游客就是一个没有邮箱的普通用户
最省事、也最不容易出错的做法,是不给游客单独建表:游客就是 rk_user 里的一行,多一个 guest 标记。
-- 游客没有邮箱:email 允许为空(唯一索引对多个 NULL 不冲突)
ALTER TABLE `rk_user`
MODIFY COLUMN `email` VARCHAR(128) NULL COMMENT '注册邮箱,统一存小写,唯一;游客为空',
ADD COLUMN `guest` TINYINT NOT NULL DEFAULT 0 COMMENT '1 游客(免注册试用,凭 openid 识别),绑定邮箱后转正为 0' AFTER `wx_openid`,
ADD COLUMN `invited_by` BIGINT NULL COMMENT '通过谁分享的链接进来的 rk_user.id' AFTER `guest`,
ADD INDEX `idx_rk_user_invited_by` (`invited_by`);
几个细节:
- 邮箱留空,不填占位邮箱。MySQL 的唯一索引允许多个 NULL,邮箱唯一约束原样保留,游客之间也不会冲突;
- 密码存一串随机 UUID 的 BCrypt 散列。
password_hash的非空约束不用动,游客实际上没法用密码登录,转正时换成用户自己设的; - 用户名是
wx_加 10 位随机字符,字母表去掉了容易看混的 l、o、0、1,正好符合原来的用户名规则,校验不用为游客放宽。
这样做的好处在后面:次数、历史、收藏、会员、订单、菜单……所有表都按 user_id 关联,游客搭配、收藏、买会员,走的全是原来的代码。搭配次数也不区分游客,同样是每 24 小时滚动 1 次。后端对游客只多了一条限制:不能改密码,要先绑定邮箱。
四、同一个微信,始终是同一个游客
开游客的接口是 POST /api/auth/guest,请求体是 { code, inviteCode }。后端拿 wx.login 的 code 去换 openid(只用 openid,session_key 不落库),然后按这个顺序判断:
Optional<UserEntity> existing = userRepository.findFirstByWxOpenidAndGuestOrderByIdAsc(openid, 1);
if (existing.isPresent()) {
UserEntity user = existing.get();
// ... 禁用检查、记登录时间
return new AuthResult(user, tokenService.issue(user.getId()), false, 0);
}
if (userRepository.existsByWxOpenidAndGuest(openid, 0)) {
throw ApiException.conflict(ErrorCode.ACCOUNT_EXISTS, ErrorCode.MSG_ACCOUNT_EXISTS);
}
// ... 新游客的全站 / 同 IP 24 小时上限
UserEntity user = new UserEntity();
user.setUsername(newGuestUsername());
user.setNickname("炊想食客");
user.setEmail(null);
// 随机密码的散列:游客不能用密码登录,转正时换成用户自己设的
user.setPasswordHash(passwordHasher.hash(UUID.randomUUID().toString()));
user.setWxOpenid(openid);
user.setGuest(1);
- 这个微信有游客,就直接登录那个游客。删了重装、换了手机,还是同一个游客,次数不会重置;
- **没有游客、但有正式账号,返回 409
ACCOUNT_EXISTS**,提示「这个微信注册过炊想,登录一下就行」。老用户不会因为 token 过期,被悄悄塞进一个空的新账号,也不会多拿一份免费次数; - 两样都没有,才新建游客。
五、什么时候自动开游客
前端的规则放在 App.onLaunch 里:
var launch = wx.getLaunchOptionsSync() || {};
auth.rememberInvite((launch.query || {}).inv);
// 免注册试用:后台开好,用户点「开始搭配」时多半已经有了;失败的话点按钮时登录页还会再试
if (auth.canAutoGuest()) {
auth.startGuest().catch(function () {});
}
canAutoGuest() 只看本地的三件事:没登录、没有主动退出过、本机没记过「这个微信有正式账号」。
- 主动退出过:多半是要换账号登录,再自动开游客就把人又塞回去了,所以退出时记一个标记,之后不再自动开(登录页上仍可以手动点「直接试用」);
- 这个微信有正式账号:全新设备上前端无从得知,只能先请求一次,收到
ACCOUNT_EXISTS再记下来,之后登录页换成「这个微信注册过炊想,用账号登录就行」。
启动时的请求不等结果、出错也不管。如果用户手快,游客还没开好就点了「开始搭配」,会走原来的登录墙。登录墙这次只改了一处:requireLogin 默认在登录页地址后面带上 auto=1。登录页看到 auto=1 且能试用,就直接开游客、回到原来的页面,首页在 onShow 里把刚才被打断的搭配接着做完:
track.event('login_wall', { props: options.auto === '1' ? 'auto' : 'manual' });
if (options.auto === '1' && auth.canAutoGuest()) {
this.onTrial();
}
startGuest 内部用一个模块级的 promise 合并并发调用,登录页拿到的就是启动时还没回来的那一次请求,不会再发第二次;如果等待期间用户自己用账号登录了,游客会话直接丢掉,以账号为准。全项目 37 处 requireLogin 调用,只有「我的」页的「登录」按钮改成传 { manual: true },进去看到的是登录表单,其余都没动。
还有一处要补:注册页有一个必须勾选的同意框,免注册试用绕过了注册页。所以登录页的「直接试用」按钮下补了一行「使用即表示同意《用户协议与隐私说明》」,隐私说明同步写上试用账号、使用统计和分享邀请分别记了什么。
六、绑定邮箱就是原地转正
游客想换手机也能登录,就去「我的」页绑定邮箱。这里没有新开接口,复用原来的 /api/auth/register:请求带着有效的游客 token,就在同一行上转正。
UserEntity guest = guestUserId == null ? null : userRepository.findById(guestUserId).orElse(null);
if (guest != null && !guest.isGuest()) guest = null;
// ... 用户名 / 邮箱 / 手机号唯一性检查
if (guest != null) {
// 游客转正:已经占过一个号,不再数 IP 注册名额;被封 / 注册受限的 IP 照样拦
requireRegisterAllowed(clientIp);
emailCodeService.verifyAndConsume(email, EmailCodeEntity.PURPOSE_REGISTER, emailCode);
return upgradeGuest(guest, username, email, password, nickname, phone, request.getCode(), clientIp, now);
}
enforceIpRegisterLimit(clientIp, now);
upgradeGuest 改的是用户名、邮箱、密码、昵称、手机号,把 guest 置 0(顺带刷新登录时间和 IP);id、创建时间、邀请关系都不动。因为所有数据都挂在 user_id 上,转正不需要迁移任何一张表。如果游客和正式账号是两张表、两个 id,这里就得把十来张表的数据挪过去,还要处理挪到一半失败。
测试里把这几条钉成了契约:转正后还是同一个 id、guest 为 false;已经用掉的那次免费搭配跟着走,不会再送一份;能用新设的账号密码登录;同一个微信再要游客,返回 ACCOUNT_EXISTS。前端也只多一个分支:同一张注册表单,当前是游客时标题换成「绑定邮箱」,请求带上游客 token。
七、新账号类型上线,防刷计数要跟着改口径
炊想原来有两道注册防刷:同一 IP 24 小时内注册数到上限就限制这个 IP 注册,全站 24 小时注册数也有总闸。两者原来都是直接数 rk_user 的行。
游客一上线,这个口径就不对了:游客是打开时静默建的,而运营商的出口 IP 很多人共用,一个 IP 上来几个新游客,正式注册的名额就被占满,想绑定邮箱的人反而被拦下。
改法是把两类账号分开计数,两道上限都只数 guest = 0:
// 与 IP 无关的全站总量:换 IP 批量养号时,每账号的免费额度不至于被无限放大
userRepository.countByGuestAndCreatedAtAfter(0, now.minusHours(IP_REGISTER_WINDOW_HOURS));
// 只数正式注册:游客另有自己的上限(运营商出口 IP 很多人共用)
userRepository.countByGuestAndRegisterIpAndCreatedAtAfter(0, clientIp, now.minusHours(IP_REGISTER_WINDOW_HOURS));
- 新游客另有一套同 IP、全站的 24 小时上限,超了提示「今天新来的朋友太多了,注册一个账号接着用吧」;
- 转正这次请求不再数 IP 注册名额(这一行建游客时已经占过一个号),但被封禁、被限制注册的 IP 照样拦,不能借转正绕过去。
功能本身测得通,这一处却很容易漏:只要多了一种账号,所有「数用户」的地方都得重新过一遍口径。后台概览里「有次数包的人数」也是同样的道理,下一节会说到。
八、分享邀请:双方各得 1 次
邀请码由用户 id 现算出来,做了一层混淆,不建表、不落库,只为不在链接里直接露出自增 id,并不当作凭证用。分享时由 sharePath 拼到路径上:
/** 给分享路径带上自己的邀请码:path 可以已有 query */
function sharePath(path) {
var code = inviteCode();
var p = String(path || config.HOME_PAGE);
if (!code) {
return p;
}
return p + (p.indexOf('?') >= 0 ? '&' : '?') + 'inv=' + encodeURIComponent(code);
}
朋友点开卡片,冷启动在 onLaunch、热启动在 onShow 里读到 inv,没登录才记下,接着随 /api/auth/guest 一起交给后端。
后端的规则:
- 只奖励新建的号。只有新建游客、或者直接注册新号的那一刻才写
invited_by、发奖励;老游客再点别人的链接不发,游客转正也不再发第二次;邀请人和新号是同一个微信时不认; - 奖励复用次数包。在权益表里给双方各存一份 1 次、30 天有效的次数包,扣次数、到期、后台收回都沿用原来那一套。扣的顺序是免费额度 → 会员 → 次数包(次数包之间先扣快到期的),所以送的次数排在免费额度和会员之后才用;后台统计「有次数包的人数」时把它们排除,免得混进付费数据;
- 上限只卡邀请人。邀请人每 24 小时最多得 5 次、累计最多 50 次;被邀请的新人不受影响,总能拿到自己那 1 次。
save(invitee.getId(), PRODUCT_INVITEE, "受 #" + inviterId + " 邀请", now);
long today = entitlementRepository.countByUserIdAndProductIdAndCreatedAtAfter(inviterId, PRODUCT_INVITER, now.minusHours(24));
long total = entitlementRepository.countByUserIdAndProductId(inviterId, PRODUCT_INVITER);
boolean inviterRewarded = today < cfg.getInviteDailyCap() && total < cfg.getInviteTotalCap();
if (inviterRewarded) {
save(inviterId, PRODUCT_INVITER, "邀请了 #" + invitee.getId(), now);
}
测试里专门有一条:同一个邀请码连续进来 6 个新游客,6 个新人都拿到 1 次,邀请人只加了 5 次,受邀人数照记 6 个。
九、埋点和漏斗
拆完墙,要能看见效果。埋点有三条原则:不影响使用、不用 openid、开发调试不污染数据。
设备号是本机随机生成的一串字符,不是 openid,清缓存就换新的。漏斗按它去重,所以没登录的人也能统计到。
页面浏览自动记。app.js 最前面调用 track.installPageHook(),把全局 Page 包一层,每个页面 onShow 时记一条,十几个页面不用逐个手写:
var origin = Page;
var wrapped = function (options) {
options = options || {};
var onShow = options.onShow;
options.onShow = function () {
try {
event('page', { page: this.route });
} catch (e) {
/* 埋点异常不影响页面 */
}
if (typeof onShow === 'function') {
return onShow.apply(this, arguments);
}
};
return origin(options);
};
wrapped.__cxTracked = true;
Page = wrapped;
其余是几个关键动作:打开(带场景值)、点搭配、看到登录页、试用成功或失败、绑定邮箱、出菜、分享、点购买。「点搭配」记在登录墙之前,没登录时多带一个标记,这样被墙拦下的点击也数得到。这正是第一节里日志看不到的那一步。
攒批上报。攒满 10 条或等 3 秒发一次,一次最多 30 条,切后台时立即发;失败就丢,不重试。
只收正式版。开发版、体验版照常上报,带上 env(取自 envVersion),由后端丢掉。前端只有一条代码路径,开发时走的就是线上那条。
后端这边:事件名有白名单,设备号格式不对整批丢弃,同一 IP 每分钟限量,入库时间用服务器时间,每天凌晨定时删掉 120 天前的数据。数据不合格只丢掉、返回保存了几条,不报错。
漏斗按北京时间自然日出数:打开、看首页、看菜谱、点搭配、看到登录页按设备去重;新账号、仍是游客、受邀、新人出菜直接查用户表和搭配记录。最后一行「合计」是对整段时间重新去重,不是把每天的数加起来,同一台设备连着几天打开只算一次。打开来源按场景值归成搜索、分享卡片、扫码、最近使用等几类。
博客后台的《小程序管理》概览加了一张「拉新漏斗(近 7 天)」。它是单独取数的,取不到只显示一行提示,概览照常显示,这样博客和小程序后端可以不同时发布。
上线一周后按这张表看:
- 「打开」很少:问题在入口,得去做分享和内容;
- 「看首页」多、「点搭配」少:首页没讲清楚能干什么;
- 「点搭配」多、「新人出菜」少:试用或出菜环节出了错,去看失败事件带的错误码。
十、上线以后
2.0.16 在 10 月 10 日中午发布,12:35 收到第一条正式版埋点。
第一个碰到新逻辑的,是一个早就注册过正式账号的微信:埋点里收到一条 guest_fail,错误码 ACCOUNT_EXISTS。按设计,这台手机从此不再自动开游客,登录页换成「用账号登录就行」,不会多出一个空的游客号。
当天下午,正式版来了第一个游客。对着埋点和搭配记录看:
| 游客开好后 | 发生了什么 |
|---|---|
| 第 0 秒 | 从微信下拉的「最近使用」打开,游客开好 |
| 第 14 秒 | 搜了一道菜,站里已有现成做法,不占次数,当场出菜 |
| 第 25 秒 | 又点了一次随机搭配,用掉免费的那一次,53 秒后搭好 |
从游客开好到搜出一桌饭用了 14 秒,没有经过注册页。
数据还太少,看不出转化,一周后再看漏斗。但至少「想搭一桌饭得先注册」这一关已经没有了。
十一、几点体会
- 第一版就该带埋点。这次只能靠 nginx 日志里 Referer 的版本段倒推访客,按 IP 算,连谁走到过登录页都看不出。「零个人申请验证码」够下决心拆墙,更细的问题只能猜。
- 门槛放在价值后面。先让人搭出一桌饭,等他想换手机也能登录、想保住记录时,再让他绑定邮箱。
- 新身份尽量复用旧模型,代价是重新数一遍人。游客是
rk_user里多一个标记,转正是改一行;但注册防刷的两道上限、后台「有次数包的人数」都得跟着改口径。 - 奖励只给新建的号,上限只卡邀请人,并写成测试。规则一多,靠记忆是守不住的。
- 隐私说明跟着功能一起改。新记了什么、保存多久、用来做什么,上线前写进去。
相关:小程序分享卡片被分享签名校验降级的排查 记录的是分享卡片的另一个坑,邀请链接靠的就是这张卡片。

评论区 0