一锅端掉微信公众号-小程序的用户资料获取

本篇手记,旨在解决微信跨产品链路中的用户资料种种痛点,业务场景解惑与技术实现细节并存,约 4000 字,请耐心阅读。

这几年的社交,是微信的社交 这几年的微信开发,是基于微信公众号的开发 这几年的公众号还没折腾明白,小程序便迫不及待扑面而来 这几年的挣扎开发历程,总是漫不经心却时光飞逝的几年…

昨天的旧票据还能否登上你的破船

我想,任何一个经历过微信公众号开发的同仁,肯定有过骂娘的夜晚,刚吭吭哧哧搞定内网端口映射到外网域名,调通后台 URL 接入认证,就掉入到 accesstoken 的坑,有基础版的 accesstoken,又有网页版的 access_token,有订阅号的 token 权限,又有服务号的 token 权限,有认证过的订阅号的 token 权限,又有认证过的服务号的 token 权限,有一些每天限制调用次数,有一些不限,有一些可以刷新获取,有一些则不能,最怕最怕公司产品既有订阅号,又有服务号,还有小网站,于是又掺和进来了 UnionID,噩梦不醒… Scott 决定从微信第一大坑入手,彻底弄清楚通过 token 获取用户资料的场景和流程。

8 种不同的用户资料获取场景

别怕,只有 8 种而已。先搞定 access_token,我们再把魔爪伸向用户。 进入微信公众平台技术文档,映入眼帘的是这样一段话:

公众号调用各接口时都需使用accesstoken。开发者需要进行妥善保存。accesstoken的存储至少要保留512个字符空间。accesstoken的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的accesstoken失效

我们从中可以得到如下几条信息:

  1. 学名叫公众号的全局唯一接口调用凭据
  2. 公众号各个接口调用都依赖
  3. 开发者需要自行保存
  4. 它的有效期是 2 小时
  5. 它需要定时刷新
  6. 每获取一次,会让之前已经获得的失效

基于这几个信息,该祭代码了:

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
}

官方文档中还有这样一句话:

在刷新过程中,中控服务器对外输出的依然是老accesstoken,此时公众平台后台会保证在刷新短时间内,新老accesstoken都可用,这保证了第三方业务的平滑过渡

保证在刷新短时间内 是一个什么概念呢,刷新要多久,是 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 种情况,就问你怕不怕。 我们总结一下:

  1. 只有认证过的订阅号/服务号,才能读取关注粉丝的用户资料
  2. 只有认证过的服务号,才能通过网页授权读取非关注用户资料

并且对于网页授权读取用户资料,是认证服务号的特权,获取方式也是非同凡响,我们后面来谈。 获取关注粉丝用户信息 上面我们拿到了 公众号的全局唯一接口调用凭据 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 端的微信用户扫码登录,那么整个场景又会略有不同,限于篇幅,我们下一次来探讨,文章有不当纰漏支持,请不吝指出。

作者: Scott 链接:http://www.imooc.com/article/19204 来源:慕课网 本文原创发布于慕课网 ,转载请注明出处,谢谢合作!