做小程序开发,绕不开“小程序开放接口”里登录和用户信息这块。很多开发者以为拿到 wx.login 的 code 就完事了,结果后面用户信息采集、session_key 校验、真机兼容问题全冒出来。这篇文章把我调这套接口的验证过程和踩坑记录整理出来,从登录机制讲到用户信息接口的合规变化,再到开放数据解密和常见问题定位,适合刚接手小程序的新手,也适合想系统性回顾的进阶开发者。
1. 登录机制的设计思路,理解它才不会被开放接口卡住
1.1 为什么小程序登录不走传统账号密码
传统 Web 登录是前端把用户名密码发给服务端,服务端校验后返回 token。小程序没有这么做的原因很简单:用户在你的小程序里拿到的是平台提供的身份,你要让他再注册一套账号密码,不仅体验割裂,还要承担密码存储、找回、风控的维护成本。平台也不希望你把用户密码流程搬到它容器里,这会带来大量安全和隐私问题。
于是方案变成了“临时凭证换身份”。前端调用 wx.login 拿到一个 code,把这个 code 给服务端,服务端拿 code 加上 appid、secret 去平台接口换取 openid 和 session_key。openid 就等价于这个用户在当前小程序里的身份证号。这个设计把敏感信息留在了服务端,前端只做“传递临时票据”的活,攻击面小很多。
只要理解这条链路,后面看所有登录相关的接口都不会慌。
1.2 wx.login 返回的 code 到底是什么
wx.login 执行成功后,success 回调里会返回 res.code。这个 code 不是用户身份,也不是 token,它是一张“一次性入场券”。它的有效期大约 5 分钟,而且用一次就失效。你把它交给后端以后,后端拿它去换 openid,换完这张票就作废了。
设计成短有效期、一次性使用,主要是为了降低票据被盗用的风险。即使攻击者通过网络抓包拿到了 code,他也只能在后端换一次身份凭证,而且必须在几分钟内完成。如果 code 可以被长期使用,那泄露一次就等于用户身份永久失守。
实操里最常见的问题是把 code 缓存在全局变量或 Storage 里,隔一段时间再传给后端,等后端发现过期已经晚了。正确做法是:每次需要建立登录态的时候,现场 wx.login,拿到 code 立刻请求服务端。
1.3 openid、unionid、session_key 的分工
这三个字段很多人混在一起,其实用途完全不同。openid 是用户在当前小程序的唯一标识,不同的应用对同一个用户会生成不同的 openid。unionid 是同一开发者主体下,多个应用之间通用的用户标识,前提是你把应用绑定在开放平台账号下。session_key 是本次会话的密钥,只用来解密和校验,不能作为业务主键,更不能下发给前端。
它们都在服务端获取,前端最多能拿到服务端处理后的 token。unionid 也建议在服务端解析,不要试图在端上拼接。面试或自查的时候,能清楚说出这三个字段的含义,基本就说明对登录体系入门了。
2. 登录态与用户身份的实际落地,代码一步步跑通
2.1 前端第一步:wx.login 之后不要急着拿用户信息
前端逻辑其实很薄。调用 wx.login,拿到 code,然后通过 wx.request 发给自己的服务端,服务端返回业务 token 后存起来。很多新手在这里会犯一个顺序错误:先调 wx.getUserProfile 再 wx.login,或者把用户信息请求和登录请求并行发。用户信息接口需要登录态,必须等 token 就绪后再拉取。
这里给一个最小示例:
wx.login({ success(res) { if (!res.code) { console.error('登录失败', res.errMsg); return; } wx.request({ url: 'https://yourdomain.example.com/api/login', method: 'POST', data: { code: res.code }, success(response) { const { token } = response.data; if (!token) return; wx.setStorageSync('token', token); // 后续请求通过 header 携带 token }, fail() { wx.showToast({ title: '网络异常', icon: 'none' }); } }); } });注意 wx.login 本身不需要用户手动触发,它是静默的。但也别在页面 onLoad 里无限调用,要加防重逻辑,否则网络慢时可能出现多个 code 并发,服务端拿到重复或过期 code,报 40029。
2.2 服务端换 openid,参数一个都不能错
服务端接到的 code 不能直接当身份用,它要拿着 appid、secret、js_code、grant_type 四个参数去调用平台提供的 jscode2session 接口。这里最容易出三个问题:
第一,appid 和 secret 必须在服务端环境变量里配置,不能打包进小程序代码,也不能通过某个查询接口从前端下发。第二,js_code 对应前端传来的 code,必须原样传递,不能做 URL 编码转义错误。第三,grant_type 在调用 jscode2session 时固定为 authorization_code,少传会直接报错。
返回体里通常有 openid、session_key,如果用户关联了开放平台,还会有 unionid。如果返回了 errcode 和 errmsg,先检查参数和网络。
给一个 Node.js 服务端示例:
const https = require('https'); const querystring = require('querystring'); function code2Session(appid, secret, jsCode) { const params = querystring.stringify({ appid, secret, js_code: jsCode, grant_type: 'authorization_code' }); const url = `https://api.weixin.qq.com/sns/jscode2session?${params}`; return new Promise((resolve, reject) => { https.get(url, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }).on('error', reject); }); }拿到 openid 和 session_key 后,服务端需要自己生成业务登录态,返回给前端。比如生成一个随机 token 或者 JWT,用 Redis 存 token 到 openid 的映射,并设置过期时间。前端后续请求 header 里带这个 token,服务端解析后定位用户。不要用 session_key 当登录凭证,session_key 是给解密和签名校验用的,不是给业务鉴权用的。
2.3 登录态续期与掉线处理的细节
登录态永远会过期。我建议服务端给前端返回两个字段:token 和 expires_in。前端存储时记录过期时间,在请求拦截器里判断是否临近过期,如果剩余时间低于阈值,就主动重新走一遍 wx.login。不要每次接口报 401 就弹登录框,体验很差。
还有一种情况,用户长期不打开小程序,session_key 早就失效了。当你下一次要解密数据时,服务端拿旧的 session_key 去解,会直接失败。这时候要让服务端返回“登录态失效”标记,前端收到后再触发 wx.login 重新建立会话。时序上要先确认服务端 code2Session 接口调用成功,再写缓存,否则可能出现缓存了旧 key 导致解不开的问题。
3. 用户信息接口的变迁,别再拿老代码硬套
3.1 getUserInfo 为什么不能用了
过去的小程序生态里,很多开发者一进页面就调用 wx.getUserInfo,弹一个授权窗口让用户给头像和昵称,拿到手之后存到自己的数据库。这套方式在一段时间内很普遍,但后来平台收紧了用户隐私策略,wx.getUserInfo 不再弹窗,也不返回真实用户信息,而是给一份匿名数据。你再写老教程里的 getUserInfo,会发现拿到的头像和昵称是空壳或者默认值,根本没法当身份资料用。
本质上,用户信息采集从“开发者主动抓取”转变成了“用户主动提供”。如果业务需要展示头像昵称,就要引导用户自己选,而不是偷偷拉取。
3.2 getUserProfile 的适用场景与限制
wx.getUserProfile 曾被认为是 getUserInfo 的替代品。它必须由用户点击事件触发,开发者要填一个 desc 说明用途。刚上线时确实能拿到头像和昵称,但后来平台对这类接口也做了回调限制:用户已经授权过的场景下,再次调用可能直接返回匿名数据;部分基础库版本之后,getUserProfile 基本拿不到真实信息。
所以我不建议新项目再把 getUserProfile 作为主力方案。如果老项目还在用,尽快压测一下不同基础库版本的表现。也可以用它配合一次性的“完善资料”按钮,让用户主动点击授权,但如果发现返回的是匿名信息,就要马上切换到新的头像昵称填写方案。
3.3 用头像昵称填写能力,最稳的合规姿势
现在要拿用户头像和昵称,推荐的方式是让用户自己选和填。页面里放一个 button,open-type 设为 chooseAvatar,用户点击后会弹起头像选择器,选完从 bindchooseavatar 事件里拿到一个临时头像文件路径,然后通过 wx.uploadFile 上传。昵称则用 input,type 设为 nickname,用户在输入时系统会给出联想建议,输入后从 bindchange 或表单提交事件拿到值。
示例代码:
<button open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"> 选择头像 </button> <input type="nickname" placeholder="请输入昵称" bindblur="onNicknameChange" />Page({ onChooseAvatar(res) { const tempFilePath = res.detail.avatarUrl; // 将 tempFilePath 上传到自己的对象存储或服务器 }, onNicknameChange(e) { const nickname = e.detail.value; // 保存昵称 } });这套方案的好处是不再依赖授权弹窗,头像和昵称都是用户主动给出的,合规上风险低。头像上传后,最好做一遍大小裁剪、格式校验,避免用户选一张几 MB 的图直接传上来,又慢又费流量。
4. 开放数据校验与解密,这步经常被忽略
4.1 签名校验到底在校验什么
平台会开放一些受信任的数据给开发者,但网络传输过程可能被篡改。小程序返回数据时,会同时给一个 signature 字段。服务端拿到后,要把 rawData 加 session_key 拼接起来,做一次 SHA1 摘要,然后和 signature 比对。如果对不上,说明数据不是来自平台,必须丢弃。
伪代码同样适用:
const crypto = require('crypto'); function verifySignature(rawData, sessionKey, signature) { const hash = crypto.createHash('sha1') .update(rawData + sessionKey) .digest('hex'); return hash === signature; }很多开发者只做解密不做签名校验,等于把“伪造数据”的入口留着。我遇到的案例里,有同学直接把数据存库并展示,后来发现头像被改成广告图,就是因为没校验签名。
4.2 encryptedData 和 iv 的解密细节
某些开放接口的数据会通过 encryptedData 和 iv 返回,比如早期版本的手机号、运动步数等。解密算法是 AES-128-CBC,密钥是 session_key 的前 16 个字节,偏移量是 iv,填充方式是 PKCS7。解密后的明文是 JSON 字符串,里面通常包含 openid、unionid、水印信息,以及业务数据字段。
服务端示例:
const crypto = require('crypto'); function decryptData(encryptedData, iv, sessionKey) { const key = Buffer.from(sessionKey, 'base64'); const ivBuffer = Buffer.from(iv, 'base64'); const encrypted = Buffer.from(encryptedData, 'base64'); const decipher = crypto.createDecipheriv('aes-128-cbc', key, ivBuffer); decipher.setAutoPadding(true); let decoded = decipher.update(encrypted, null, 'utf8'); decoded += decipher.final('utf8'); return JSON.parse(decoded); }注意 sessionKey 必须是当前有效会话的,不要提前缓存。解密失败时,先检查 base64 字符串是否完整,再看看是不是 session_key 过期。另外一个高频坑是语言实现问题:部分语言库默认填充方式不是 PKCS7,要显式指定,否则报错。
4.3 手机号快速验证组件与新版 code 换号流程
获取用户手机号,眼下最稳的是 button 的 open-type 设为 getPhoneNumber。用户点击后,在 bindgetphonenumber 回调里会拿到一个 code 字段。注意,这个 code 跟 wx.login 的 code 不是一回事,它是一次性的动态令牌,直接传给服务端,服务端通过平台接口换取手机号。
这种方案比旧的解密手机号方式更简洁,因为服务端换取时用的是 access_token 而不是 session_key。不过 access_token 本身有有效期和频率限制,服务端要做缓存。换取手机号成功后,手机号属于敏感个人信息,后端要加密存储,前端不要打印到日志里。
5. 常见问题与排查技巧实录
5.1 code2Session 返回 40029 或 45011 怎么查
40029 表示 code 无效。先看前端是不是重发了同一个 code,再看是不是 code 生成后超过 5 分钟才传到服务端。还有一种隐蔽问题是服务端日志里记录了几次失败的 code2Session 调用,但前端认为拿到 code 就万事大吉,实际上并没有防止重复提交。
45011 是频率限制。通常是某一个 appid 在短时间内调用了太多次 code2Session,被临时拦截。缓解办法是在服务端加缓存,同一个 code 只请求一次,同一用户短时间不重复换。别用同一个 appid 在多个后端环境同时联调,也很容易触发限制。
5.2 用户拒绝授权后的体验救场
无论 chooseAvatar 还是 getPhoneNumber,用户都可能点拒绝。开发时别只在 fail 里 console.log 一下,要想清楚用户后续入口。比较好的做法是:个人信息页放一个醒目的“完善资料”按钮,用户点了才拉起选择器;如果拒绝,第一次温和提示可以不填,后续通过按钮仍然可以重新触发,而不是一进页面就强制弹窗。
有人为了拿手机号,在用户拒绝后连续弹三个确认框,这属于把用户往死里逼。最后用户虽然给了,但给了差评,得不偿失。
5.3 真机调试与开发者工具表现不一致
这是个非常经典的问题。开发者工具里一切正常,一上真机,用户信息拿不到、签名校验不过。多半是本地调试用了假的 session_key,或者开发者工具缓存了旧的登录态。解决方法是把小程序在开发者工具里清缓存,再在真机冷启动一次。
另外要注意,开发者工具的“不校验合法域名”开关只影响开发环境,真机上如果 https 域名没有在后台配置,请求会被拦截。这个和登录接口无关,但经常和登录失败一起出现,排查时要先区分是网络层挂了还是业务层失败。
5.4 token 过期与 session_key 失效的时序问题
我踩过的一个坑是这样的:前端因为业务需要调了一次 wx.login,后端换到新的 session_key,但数据库里还存着旧的 token 映射。之后解密接口用新 session_key 解旧 token 关联的数据,怎么都解不开。后来解决方式是,在每次 token 续期或重新登录时,服务端把 token 和最新 session_key 一起更新,前端不再维护多个身份凭证。
排查这类问题时,建议在服务端日志里记录 code2Session 请求时间和 token 更新时间。只要时间线对得上,基本就能定位。
6. 我自己在项目里养成的几个小习惯
先说日志。登录和用户信息的接口日志必须记录关键参数,但不能把 openid、手机号、session_key 每次完整打到日志里。我会把用户标识做脱敏,比如只保留前四位和后四位,session_key 只在调试环境输出。这样出了问题能定位,又不会把敏感数据留在日志文件里。
再说密钥管理。appid 和 secret 不要写死在代码里,更不能提交到仓库。我在本地用环境变量,生产环境用配置中心或云上密钥管理服务,权限最小化。secret 一旦泄露,任何人都能拿它换用户身份,后果比想象中严重。
最后是数据最小化。用户信息能少存就少存,能异步更新就不强求实时。头像、昵称、手机号这种敏感字段,前端展示归展示,后端存储要加密,而且只存业务真正需要的字段。这套接口说白了都是围绕“身份”展开的,你对自己服务端的信任边界画得越清晰,后面越不容易被突然的规则调整打乱。
再补充一个细节:联调阶段不要把 appid 和 secret 发给外部同事,也不要贴到群里。如果确实需要临时调试,用测试号隔离,调试完马上回收。我的习惯是每次上线前检查一遍环境变量,确认线上配置不是测试值。这个小动作看着简单,真能避免很多半夜惊醒的事故。