4.1 登录体系:openid 与手机号授权

场景

登录这个环节,小项目最容易犯的错是“不做”或者“做重”。我们的第一版根本没有登录——下单时云函数自动从上下文拿 openid,顾客无感。看起来挺好,直到朋友要搞会员积分和复购提醒:没手机号,会员体系是空的;拿不到一个稳定联系方式,顾客付完款就走,复购全靠命。

但“做重”的坑我们也踩过:早期版本做的是一进来就弹登录授权页,必须授权手机号才能浏览商品。转化率当天腰斩——顾客还没看到货就被要手机号,跑得比谁都快。朋友那天看我一眼:你网购时会先填手机号再逛吗?我问住了。登录设计的核心矛盾是:业务需要身份信息,而顾客的耐心在到店第一秒是最薄的。

还有一个技术层面的弯路值得说:最初我把 openid 当作用户唯一标识来做业务关联(订单、购物车、收藏),后来发现unionid 这回事——同一家主体的多个小程序/公众号之间,openid 是不同的,想跨应用打通会员体系得用 unionid。我们目前只有一个小程序,openid 够用,但用户表里给 unionid 留了字段。设计上向前看一步,省得将来重构。这节讲我们最终怎么搭一套“静默登录打底、手机号按需授权”的体系。

还有一层背景值得交代:为什么要花力气做登录。朋友店里复购率很高——老顾客隔三差五回来买杯子送人。没有登录体系时,这些复ook 顾客每次都是“新面孔”,她想给老客发张优惠券都找不到人。做过零售的人都明白一个数字:拉一个新客的成本是维护一个老客的好几倍。登录体系不是为了技术完整,是为了把“回头客”这个商业事实变成系统能操作的对象。

方案

登录体系的分层设计如下:

层 作用 何时发生
静默登录 拿 openid 建立服务端身份 小程序启动时
用户档案 昵称、头像等展示信息 首次需要时引导
手机号授权 业务关键身份 下单或入会时
会话保持 登录态续期 每次 key 过期前

核心思路是“分层要数据”:openid 是云开发免费给的(云函数上下文里直接取),不消耗顾客任何耐心,就做静默登录的底座;手机号是需要顾客点击授权的,只在真正需要的地方(下单、入会)才要——这是对顾客耐心最金贵的消费。

手机号授权在当前的客户端能力下是组件化的:<button open-type=“getPhoneNumber”>,顾客点一下授权按钮,回调里拿到加密手机号,传给云函数解密入库。注意这里不能弹窗强要,得把授权按钮做成“会员福利”或“一键下单”的入口的一部分,顾客为了便利自愿点。

用户表 users 集合的设计也简洁:openid 主标识、unionid 预留、phone 加密存储、会员等级、注册时间。别在这里塞收货地址(单独一张表,多对多关系更合理),单一职责的集合以后改动成本低得多。手机号解密后我们会做一个清洗:去掉前缀加号、统一为 11 位格式再入库,否则同一个号码因为格式不同被当成两个人,会员数据就乱了。

配置/代码

静默登录在小程序启动时完成,调用一个 login 云函数。这个调用要做超时容错:网络差时失败不阻塞启动,进首页后游客态浏览,需要身份时再补一次。登录失败本身不该成为顾客进店的门槛,这条原则我们后来用在了每一个“弱依赖”接口上。

// app.js onLaunch
wx.cloud.callFunction({ name: 'login' }).then(res => {
  this.globalData.openid = res.result.openid;
  // 拉用户档案、判断是否会员等
});

云函数极简,因为 openid 是框架给的:

// login 云函数
exports.main = async (event, context) => {
  const openid = context.wxContext.OPENID;
  // 首次见到的用户建个档
  const u = await db.collection('users').where({ openid }).count();
  if (u.total === 0) {
    await db.collection('users').add({ data: {
      openid, createdAt: Date.now(), level: 1
    }});
  }
  return { openid };
};

手机号授权的按钮和解密流程。顾客拒绝授权时我们不给错误提示,按钮保持原样,等他下次需要时自然会再点。授权这件事上,不纠缠是最佳策略:

// wxml:<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhone">
async onGetPhone(e) {
  if (e.detail.errMsg !== 'getPhoneNumber:ok') return; // 顾客拒绝
  const res = await wx.cloud.callFunction({
    name: 'bindPhone',
    data: { cloudID: e.detail.cloudID }
  });
  this.setData({ phone: res.result.phone });
}

cloudID 的方式比旧的加密数据包 更简单:云函数里通过 cloudID 直接换到手机号,不用自己解密。sdk 帮你做的活,别自己再写一遍解密。顺带一个排错经验:cloudID 有效时间很短,授权回调后要立即传给云函数,前端先存本地缓存里“等会儿再传”就会偶发失败——我们那阵子“授权成功但绑定失败”的幽灵 bug 就是这个原因,查了一下午。

会话保持不用自己做:云开发自带登录态,openid 在云函数上下文里随时可取。旧时代的 session_key 维护、过期刷新这一堆活儿,云开发里整体被省掉了——选型的红利在这里体现得很具体。

再补一个实用小技巧:全局 openid 的获取时机。app.js 的 onLaunch 里调 login 云函数是常见做法,但 onLaunch 里网络请求没有超时保证,冷启动极慢时会卡住后续页面。更稳的方式是每个需要 openid 的页面在 onLoad 时检查全局变量,没有再调 login——懒加载思路,首屏不被登录拖累。另外 login 云函数做了结果缓存(两小时内不重复建档),减少不必要的调用开销。

踩坑

第一个坑就是开头说的强要授权。转化率腰斩的教训:授权请求永远出现在“顾客需要换点便利”的时刻,而不是他一进门的时刻。后来我们把授权入口只放在两个地方:结算页的“一键下单”和会员中心的“加入会员”,其余页面一律不强要。

第二个坑是 openid 前端获取。有阵子图省事,把 openid 存在本地缓存里当前端全局变量用,下单时直接传给云函数。这等于把身份验证交给了前端——抓包改一下 openid 就能冒充别人下单。最终方案:openid 只在云函数里通过上下文获取,前端传什么都不算数。

第三个坑是手机号不用 cloudID 而手工解密。旧方案的加密数据包需要 session_key、iv、自己写解密代码,还容易对不上。换成 cloudID 后代码少了大半,错误率降为零。

第四个坑是手机号保存明文。起初没想太多直接存了,后来做导出功能时惊出一身汗:导出文件里全是明文手机号。改成存掩码展示(138****8888),完整号码加密入库,导出加权限审批。顾客的数据宁麻烦自己,不辜负信任。

第五个坑是登录态与购物车合并时机。顾客静默登录后,本地购物车要并入云端车——但合并太早(启动时)会拖慢首屏。我们把合并放在打开购物车页时触发,启动只做静默登录本身,职责分开。这样做还有个好处:顾客压根没打开购物车的话,合并根本不发生,省下来的云函数调用实打实。合并失败也不要紧,待同步队列兜底,下次浏览时会再试一次。

第六个坑是授权按钮样式诱导。运营把授权按钮做成“领取 20 元优惠券”的模样,授权率确实高,但点进去才发现要授权手机号,有顾客投诉“被骗了”。授权就是授权,福利就是福利,文案必须诚实——这是底线,不是优化。

小结

登录体系重构后数据很直观:静默登录覆盖率百分百(顾客无感),手机号授权率从强制时的四成掉到按需引导后的两成,但下单转化率反而涨了三成——愿意到那一步的顾客都是真心的。上线一个月后,朋友还发现了个副产品:因为用户档案稳了,她第一次能做出“老客复购榜”,知道哪几位顾客最支持小店,过年时给她们一人寄了只新出的杯子,成本不高,心意到了——这是数据之外的人情味。这套分层设计的本质是对顾客耐心的精打细算:能不要的授权绝不要,能晚要的绝不早要。如果你打算做更完整的会员或积分体系,金融业务实战里对账户与积分模型的设计值得先读一遍,能少走不少弯路。另外提醒一句:手机号、会员等级这类字段做任何导出前,先过一遍权限审批流程(我们在 5.3 节会讲),数据越是金贵,越要慢慢花。下一节讲怎么用订阅消息把顾客“拉回来”:付款了提醒发货、发货了提醒收货。

延伸阅读