本篇手记,旨在解决微信跨产品链路中的用户资料种种痛点,业务场景解惑与技术实现细节并存,约 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 C
export 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,被你偷偷换成 B
export 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
}
// 收到小程序端发过来的请求,解析 UserInfo
export 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 端的微信用户扫码登录,那么整个场景又会略有不同,限于篇幅,我们下一次来探讨,文章有不当纰漏支持,请不吝指出。
热门评论
感谢老司机带路
123
收藏,找了半天没找到收藏的地方啊。。。。233333