一锅端掉微信公众号-小程序的用户资料获取
本篇手记,旨在解决微信跨产品链路中的用户资料种种痛点,业务场景解惑与技术实现细节并存,约 4000 字,请耐心阅读。
这几年的社交,是微信的社交
这几年的微信开发,是基于微信公众号的开发
这几年的公众号还没折腾明白,小程序便迫不及待扑面而来
这几年的挣扎开发历程,总是漫不经心却时光飞逝的几年...
昨天的旧票据还能否登上你的破船
我想,任何一个经历过微信公众号开发的同仁,肯定有过骂娘的夜晚,刚吭吭哧哧搞定内网端口映射到外网域名,调通后台 URL 接入认证,就掉入到 access_token 的坑,有基础版的 access_token,又有网页版的 access_token,有订阅号的 token 权限,又有服务号的 token 权限,有认证过的订阅号的 token 权限,又有认证过的服务号的 token 权限,有一些每天限制调用次数,有一些不限,有一些可以刷新获取,有一些则不能,最怕最怕公司产品既有订阅号,又有服务号,还有小网站,于是又掺和进来了 UnionID,噩梦不醒...
Scott 决定从微信第一大坑入手,彻底弄清楚通过 token 获取用户资料的场景和流程。
8 种不同的用户资料获取场景
别怕,只有 8 种而已。先搞定 access_token,我们再把魔爪伸向用户。
进入微信公众平台技术文档,映入眼帘的是这样一段话:
公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效
我们从中可以得到如下几条信息:
学名叫公众号的全局唯一接口调用凭据
公众号各个接口调用都依赖
开发者需要自行保存
它的有效期是 2 小时
它需要定时刷新
每获取一次,会让之前已经获得的失效
基于这几个信息,该祭代码了:
const API = '微信全局 access_token API'export async getToken() { let data = await fetchTokenFromDbOrAPI() let now = (new Date().getTime()) if (data.expires > now / 1000) { return data } // 票据过期 重新获取 data = await updateToken() // 设置到期时间 now = (new Date().getTime()) data.expires = now / 1000 + data.expires_in // 入库或同步给某个服务 await saveTokenToDbOrAPI(data) return data}export async updateToken() { const data = await request(API) return data}
官方文档中还有这样一句话:
在刷新过程中,中控服务器对外输出的依然是老access_token,此时公众平台后台会保证在刷新短时间内,新老access_token都可用,这保证了第三方业务的平滑过渡
保证在刷新短时间内 是一个什么概念呢,刷新要多久,是 100 毫秒,还是 2 秒?刷新动作发起的时时候到收到请求存入到数据库,到能对外提供服务,这中间如果有其他的用户请求触发了再次刷新,那么需要在服务器端做已发出刷新动作的统计么,需要加锁 hold 住拦截当前的刷新动作么,需要一直等到上一个刷新成功返回且存入数据库再清空刷新队列么。
为了不心烦头疼,通常我们这么干,就是加大提前量,在过期前 10 分钟 就定时主动刷新,或者对于产品容错要求不高的项目,如果用户触发了请求,只要在 10 分钟时差内,就果断刷新,反正一天的请求量是 2000 次,对于 10 分钟的时间差也足够用了,该祭代码了:
export async getToken() { let data = await fetchTokenFromDbOrAPI() let now = (new Date().getTime()) if (data.expires > now / 1000) { return data } // 票据过期 重新获取 data = await updateToken() // 设置到期时间,并缩短 10 分钟 now = (new Date().getTime()) - 600 * 1000 data.expires = now / 1000 + data.expires_in // 入库或同步给某个服务 await saveTokenToDbOrAPI(data) return data}
好,我们搞定了 公众号的全局唯一接口调用凭据, 我们有资格去请求用户资料了。
等等...UnionID 是怎么回事?OpenID 怎么办?
先别慌,我们先把订阅号服务号的边界搞清楚,这就是我说的 8 种用户资料场景。
第一种 未认证订阅号无获取用户信息权限
请登录到公众号后台,瞪大双眼看:
获取关注粉丝基本信息: 未获得
获得条件:必须通过微信认证
第二种 未认证订阅号无获取网页授权用户信息权限
请登录到公众号后台,瞪大双眼看:
网页授权获取用户基本信息: 未获得
获得条件:必须通过微信认证
第三种 已认证订阅号有获取用户信息权限
请登录到公众号后台,瞪大双眼看:
获取关注粉丝基本信息: 已获得
每日上限:500000 次
第四种 已认证订阅号无获取网页授权用户信息权限
请登录到公众号后台,瞪大双眼看:
网页授权获取用户基本信息: 未获得
获得条件:必须是服务号+必须通过微信认证
第五种 未认证服务号无获取用户信息权限
请登录到公众号后台,瞪大双眼看:
获取关注粉丝基本信息: 未获得
获得条件:必须通过微信认证
第六种 未认证服务号无获取网页授权用户信息权限
请登录到公众号后台,瞪大双眼看:
网页授权获取用户基本信息: 未获得
获得条件:必须通过微信认证
第七种 已认证服务号有获取用户信息权限
请登录到公众号后台,瞪大双眼看:
获取关注粉丝基本信息: 已获得
每日上限:500000 次
第八种 已认证服务号有获取网页授权用户信息权限
请登录到公众号后台,瞪大双眼看:
网页授权获取用户基本信息: 已获得
每日上限:无上限
轰轰烈烈的 8 种情况,就问你怕不怕。
我们总结一下:
只有认证过的订阅号/服务号,才能读取关注粉丝的用户资料
只有认证过的服务号,才能通过网页授权读取非关注用户资料
并且对于网页授权读取用户资料,是认证服务号的特权,获取方式也是非同凡响,我们后面来谈。
获取关注粉丝用户信息
上面我们拿到了 公众号的全局唯一接口调用凭据 access_token,每一次用户主动发的消息,都会发过来一个 XML 数据包,解析这个数据包后,就能拿到里面的 FromUserName,大概长这个样子:
const message = { ToUserName: 'gh_c69edc91fe37', FromUserName: 'oW4nAvpSgoLKfVDdtK_VvGutDako', CreateTime: '1500037104', MsgType: 'text', Content: 'uu', MsgId: '6442610305031245235'}
拿到后,无论在认证过的订阅号还是认证过的服务号中,就可以获取关注公众号的粉丝资料了,祭出代码:
const userAPI = '微信用户基本信息 API'export async getUserInfo(openID) { const data = await getToken() const token = data.access_token const openID = message.FromUserName const url = `${userAPI}?access_token=${token}&openid=${openID}` const userData = await request(userAPI) return userData}
似乎一切顺风顺水,那是因为关注过公众号的粉丝,在向我们推送消息时候,消息中已经包含了 openID 了,所以拼接个 url 请求就好了,但是网页中用户资料的获取就是另外一回事了。
扎心的网页 OAuth 2.0 授权
我们能搞定粉丝信息,是因为我们在公众号的内部系统中才有这个权限,脱离了公众号,游走在微信其他地方,就得依赖另外一套生存法则了,并且这套法则只对认证服务号生效,如果你的产品是订阅号,你需求方非让你在网页中照搬上面的功能,你可以把我之前列的第四种情况甩他一脸。
对于网页获取用户信息,我们需要先搞清楚什么是 OAuth 2.0,这方面文章有很多,大家可以自行补课,我把微信里的授权流程简单描述下:
用户在微信中打开你的网址 A
你在服务器里面偷换下给他重定向到网址 B
用户眼睁睁看着 B 网址展现一个是否同意授权的按钮
用户闭眼按下去,网址 B 跳到了 网址 C
你在服务器里面拿到了网址 C 上面的 code
你在服务器里面拿着 code 和 公众号 id/secret 拼了个网址 D
你在服务器里面请求网址 D 要回来 access_token 和 openID
你在服务器里面拿着 openID 去请求用户资料
不好意思,不小心又凑出来个 8 步棋..恩恩..网址 B..噗噗..openID...
只可惜,这个 openID 还是那个 openID,而 access_token 却已乾坤大魔移,该祭代码了:
const userSNSAPI = '微信 SNS 用户资料 API'const authAPI = '微信 OAuth 2.0 API'const tokenAPI = '微信网页授权 access_token API'// 此票据并不是前面的 公众号的全局唯一接口调用凭据export async getToken(code) { let data = await fetchTokenFromDbOrAPI() let now = (new Date().getTime()) if (data.expires > now / 1000) { return data } // 票据过期 重新获取 data = await updateToken() // 设置到期时间,并缩短 10 分钟 now = (new Date().getTime()) - 600 * 1000 data.expires = now / 1000 + data.expires_in // 入库或同步给某个服务 await saveTokenToDbOrAPI(data) return data}// 拼接一个微信域名的 URL B,参数放上我们真正想要跳转的 URL C// 用户打开 URL B,再点击授权按钮(微信自动展现不需我们关心),跳到 URL Cexport function oAuthURL(scope, redirect, state) { const url = encodeURIComponent(redirect) return `${authAPI}?appid=${ID}&redirect_uri=${url}&response_type=code&&scope=${scope}&state=${state}#wechat_redirect`}// http://x.o/redirect/a// 用户进入 URL A,被你偷偷换成 Bexport async visitPageA(ctx, next) { const scope = 'snsapi_userinfo' const redirect = 'http://x.o/redirect/c' const state = 'abc' const url = oAuthURL(scope, redirect, state) ctx.redirect(url)}// http://x.o/redirect/c?code=xo&state=abc// 用户进入 URL C,被你偷偷拿到 code 换数据export async visitPageC(ctx, next) { // 拿到 state 就拿到了跳转之前用户的所在状态 // const state = ctx.query.state const code = ctx.query.code const data = await getToken(code) const openID = data.openid const url = `${userSNSAPI}?access_token=${token}&openid=${openID}` const userData = await request(url) // 拿到 userData 做其他业务...}
好,总算是能拿到用户信息了,松了一口气,结果产品经理跑过来,气喘吁吁的说,兄弟兄弟,快醒醒,咱们要上小程序了,这是需求清单,照着公众号网页 App 的功能实现就行啊......
此处省略 33 小时的狂吐槽和自我心理挣扎....
没事,甩甩头,再次踏上开发小程序的战场。
全平台统一用户信息
经过一番文档各种比对,知道了,可以把小程序和公众号绑定到微信开放平台上来,这样的话,获取用户信息的时候,会拿到一个 unionID,这个 unionID 跟 openID 一样,可以获取用户的资料,不同的的是,unionID 对于同一个用户,无论他是在小程序里面,还是在公众号里面,他的 unionID 都是相同的,这样就可以通过 unionID 来识别出,通过不同平台访问我们服务的人,自然能统一掉他的账号体系。
这样一个大招,代码却并不需要做多少改动,unionID 可以直接当做 openid 来用,从前用 openid 请求用户信息的地方,现在用 openid=unionID 同样可以拿到,直接祭出代码:
// http://x.o/redirect/c?code=xo&state=abc// 用户进入 URL C,被你偷偷拿到 code 换数据export async visitPageC(ctx, next) { // 拿到 state 就拿到了跳转之前用户的所在状态 // const state = ctx.query.state const code = ctx.query.code const data = await getToken(code) // openid 可以获取后,跟既有数据库里的 openid 比对 // 比对上,就把之前的 openid 逻辑逐步干掉,替换成 unionid // const openID = data.openid // 从此拿 unionID 来请求用户信息即可 const unionID = data.unionid const url = `${userSNSAPI}?access_token=${token}&openid=${unionID}` const userData = await request(url) // 拿到 userData 做其他业务...}
小程序迎刃而解
上面我们通过 unionID 拿到了用户信息,小程序里面,代码就可以这样搞了:
export const getUserByCode = async code => { const options = { uri: 'https://api.weixin.qq.com/sns/jscode2session', qs: { appid: 'appid', secret: 'secret', js_code: code, grant_type: 'authorization_code' }, json: true } const userData = await request(options) return userData}// 收到小程序端发过来的请求,解析 UserInfoexport async getMinaUer(ctx, next) { const userInfo = ctx.query.userInfo const code = ctx.query.code const userData = await getUserByCode(code) const wxBizDataCrypt = new WXBizDataCrypt(userData.session_key) const decryptData = wxBizDataCrypt.decryptData(userInfo.encryptedData, userInfo.iv) // 解析出来 unionid const unionid = wxBizDataCrypt.unionid // ...}
于是宣告一统天下:
公众号里面,推送过来的 message XML 包,经过解析后,包含 FromUserName,就是公众号中用户的 openID,而经过 getUserInfo 后的数据中,就包含了除了 openID 外的 unionID
微信网站 App 里面,经过用户 OAuth 2.0 授权后,拿到的 openID 公众号中用户的 openID 以及 unionID
小程序里面,通过 code 再对用户的 userInfo 进行解析后,拿到的 openID 以及 unionID
以上的三个 openID 是不同的 openID,但是 unionID 却是同一个。
过渡期的用户存储
从前只有公众号的时候,获取用户资料保存信息,都是通过 openID 一网打尽,而随着业务的覆盖面,openID 切换到了 unionID,但是一开始可能是没有 unionID 权限的,或者不确定将来会不会切换到 unionID,那么可以在初次数据建模的时候,把 openID 保存一下,存成一个数组,等到将来有了 unionID 后,再逐步来筛选替换即可。
如果涉及到 PC 端的微信用户扫码登录,那么整个场景又会略有不同,限于篇幅,我们下一次来探讨,文章有不当纰漏支持,请不吝指出。
编辑:--ns868