getPhoneNumber 旧接口停用迁移记:code 换手机号的新写法与三类报错
适用读者:正在维护微信小程序登录、注册、绑定手机号链路的前后端工程师,尤其是还在用 encryptedData 解密拿手机号的存量项目维护者。
一、老代码是在一个周三早上集体罢工的
去年 11 月的一个周三,早上九点十几分,客户运营群先炸了:小程序注册页点「授权手机号」之后一直转圈,偶尔弹一个「获取失败」。我们打开后端日志,满屏都是 AES 解密返回空、session_key 校验不过的记录。那条链路我们已经跑了两年多,一行没改过,突然就挂了。
旧实现的路径是这样的:用户点open-type="getPhoneNumber"的按钮,回调里拿到e.detail.encryptedData和iv,前端再配合wx.login换来的 session_key,把密文一起传给服务端,服务端用 AES-128-CBC 解出手机号明文。这套写法在 2020 年前后是官方推荐姿势,教程满天飞。
翻平台公告才发现,2023 年 8 月底微信就发了通知:手机号获取方式整体升级为「code 换手机号」,旧的加密数据解密链路分批停用,存量小程序留了数个月缓冲期。**我们就是拖着没迁的那批,最后是线上替我们做的决定。**这次迁移前前后后踩了三类报错,把过程和结论都记下来,给还没动手的同行省点时间。
二、新写法:button 回调里直接拿 code
2.1 前端:改动比想象的小
WXML 层面几乎零改动,按钮还是那个按钮,open-type不变。变化全在 JS 回调里——不再碰encryptedData和iv,直接取detail.code。
<!-- 依赖:基础库 2.21.2 及以上才能在回调里拿到 detail.code --><!-- 按钮写法与旧版完全一致,变化全部发生在 JS 回调里 --><buttonopen-type="getPhoneNumber"bindgetphonenumber="onGetPhone"class="login-btn">授权手机号登录</button>// 页面逻辑:回调里直接取 detail.code,不再碰 encryptedDataPage({onGetPhone(e){// 用户点了「拒绝」时 detail 里没有 code,只有 errMsgif(!e.detail.code){// 拒绝授权是正常用户行为,不要弹强提示打断他wx.showToast({title:'已取消授权',icon:'none'});return;}// code 有效期约 5 分钟,且只能消费一次,拿到立刻传后端// 变量先存进局部作用域,避免异步过程中被二次取值constcode=e.detail.code;wx.request({url:'https://api.example.com/auth/phone',method:'POST',data:{code},success:(res)=>{// 后端换号成功后返回登录态与脱敏手机号// 这里只写 storage 里的 token,手机号串仅用于页面回显wx.setStorageSync('token',res.data.token);wx.showToast({title:'登录成功',icon:'success'});},fail:()=>{// 网络异常时提示稍后再试,不要在这里重发同一个 codewx.showToast({title:'网络异常,请稍后再试',icon:'none'});},});},});有两个坑要在前端就堵住。code 的有效期约 5 分钟、只能用一次,所以不要把它存进 storage、不要放进重试队列里二次发送。用户点「拒绝」时回调里根本没有 code 字段,只看e.detail.code是否存在就能分支。
2.2 服务端:拿 code 去微信换手机号
自建服务端走 HTTP 接口:POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN,请求体只有一个{"code": "..."},返回里的phone_info包含phoneNumber、purePhoneNumber、countryCode和 watermark。用云开发的项目更省事,云调用连 access_token 都不用管。
// 环境:Node.js 18+,依赖 express 4.x、axios 1.x// access_token 统一收敛在 tokenManager 模块里发放与刷新constexpress=require('express');constaxios=require('axios');constapp=express();// 解析 JSON 请求体,前端传的是 { code: 'xxx' }app.use(express.json());// 官方换号接口:query 上挂 access_token,body 里只放 codeconstPHONE_URL='https://api.weixin.qq.com/wxa/business/getuserphonenumber';app.post('/auth/phone',async(req,res)=>{constcode=(req.body||{}).code;// 入口先挡空值,避免拿空 code 去打微信接口白白耗调用次数if(!code){returnres.status(400).json({msg:'code 缺失'});}try{// token 从统一发号器取,内部带提前刷新与并发去重// 千万不要在每个接口里各自调 getAccessToken,会互相顶掉consttoken=awaittokenManager.getToken();// 微信对 -1 系统繁忙的建议是原样重试,这里做最多 3 次退避// 重试期间不要改请求参数,-1 只认原样重发for(leti=0;i<3;i++){constresp=awaitaxios.post(PHONE_URL,{code},{params:{access_token:token},timeout:5000,});const{errcode,phone_info}=resp.data;// 结构先解构出来,后面所有分支都基于这两个字段判断// errcode 为 0 表示成功,purePhoneNumber 不带区号,落库用它// phoneNumber 与 purePhoneNumber 的区别是前者可能带 86 前缀if(errcode===0){// 手机号是敏感个人信息,前端只给脱敏串,明文只留服务端constmasked=phone_info.purePhoneNumber.replace(/(\d{3})\d{4}(\d{4})/,'$1****$2');returnres.json({phoneMasked:masked,token:issueToken(masked)});}// 40029 说明 code 过期或已被消费,不要重试,让前端重新拉授权if(errcode===40029){returnres.status(409).json({msg:'code 已失效,请重新授权'});}// 其余错误码先记日志,按 200ms 递增等待后再试console.warn('getuserphonenumber fail',errcode,resp.data.errmsg);awaitnewPromise((r)=>setTimeout(r,200*(i+1)));}// 三次都失败就返回通用错误,前端提示稍后再试returnres.status(502).json({msg:'换号失败,请稍后再试'});}catch(err){// 网络层异常与业务错误分开记,方便区分是微信侧还是自己侧的问题console.error('phone exchange error',err.message);returnres.status(500).json({msg:'服务异常'});}});云开发侧的等价写法更短,适合不想自己维护 access_token 的团队:
// 依赖:云函数环境里的 wx-server-sdkconstcloud=require('wx-server-sdk');// 初始化时跟随当前云环境,避免写死 envIdcloud.init({env:cloud.DYNAMIC_CURRENT_ENV});exports.main=async(event)=>{// 云调用不需要 access_token,平台会用云函数身份代持凭证// 入口同样建议判一次空,防止前端把空 code 直接丢进来if(!event.code){// 与 HTTP 版保持一致的错误语义,前端好做统一处理thrownewError('code 缺失');}constres=awaitcloud.openapi.phonenumber.getPhoneNumber({code:event.code,});// 返回结构与 HTTP 接口的 phone_info 一致,可直接取 purePhoneNumberreturnres.phoneInfo;};新旧两条链路放在一起看,差别就很直观了:
新链路里前端彻底退出密码学环节,只是个 code 的搬运工。
三、三类典型报错与排查路径
灰度第一周,监控里集中出现过三类 errcode,每一类都有明确的触发条件。先给一张对照表,再逐个展开。
| errcode | errmsg | 高频原因 | 处置动作 |
|---|---|---|---|
| 40029 | invalid code | code 过期、重复消费、权限未开通 | 丢弃 code,前端重新拉起授权 |
| 41030 | invalid page | 页面路径不在 app.json 里 | 校正 page 参数,与 app.json 严格一致 |
| -1 | system busy | 微信侧抖动 | 原样重试,指数退避,上限 3 次 |
3.1 40029:invalid code
这是出现频率最高的一类。复盘下来有三种来源:一是灰度期部分用户点完按钮之后等了很久才联网提交,code 过了 5 分钟;二是前端异常重试逻辑把同一个 code 发了两次,第二次必挂;三是有一台测试机用的是体验版,对应的小程序还没在 mp 后台完成手机号权限的申请,调接口直接报 40029。排查顺序建议从「code 是不是只发了一次」查起,再看权限配置,代码正确性反而是最后才需要怀疑的。
3.2 41030:invalid page
这类报错严格说不是换号接口本身的错,而是迁移时顺手加的兜底逻辑带出来的。我们的设计是授权失败后下发一条订阅消息引导用户重试,下发时page参数写成了带 query 的完整路径pages/login/index?from=fallback,而 app.json 里注册的是pages/login/index,路径不一致直接 41030。教训很清楚:凡是接口签名里带 page 参数的,取值必须能在 app.json 的页面列表里逐字找到,query 拆出去另传。
3.3 -1:系统繁忙与重试策略
-1 是微信侧的瞬时抖动,官方文档明确建议原样重试。我们一开始偷懒直接透传错误,白天高峰期成功率被拉低了一截;改成服务端做 3 次指数退避重试(200ms、400ms、600ms)之后基本抹平。要注意 -1 的响应体里没有phone_info字段,重试前先判空,别让空指针把服务打挂。
3.4 access_token 管理不当的连带问题
迁移上线第二天,另一条业务线突然报 access_token invalid。追查发现是运维为了「保险」,在换号服务所在的机器上也部署了一个 token 定时刷新脚本——两个实例各自调getAccessToken,互相把对方的 token 顶失效。**access_token 必须中心化:单点生成、统一缓存、提前几分钟刷新,或者直接改用官方的 stable_token 接口,天然规避互相顶掉的问题。**这也是很多团队迁移新接口时最容易忽视的隐性依赖。
| 方案 | 问题 | 建议 |
|---|---|---|
| 各服务自行刷新 token | 互相顶掉,随机性 invalid | 收敛到统一发号器或 Redis 缓存 |
| 用 stable_token 接口 | 无 | 官方保证窗口期内返回同一凭证 |
四、原理剖析:微信为什么放弃前端解密
旧方案的根本问题在于把密码学材料摊在了客户端。session_key 要先通过wx.login换取,而它有个出名的脾气:只要授权回调之后前端又调了一次wx.login,session_key 就被刷新,旧的那把钥匙解不开新的密文,报 -41003。无数教程和踩坑帖都在教人「登录流程里 wx.login 只能调一次」,本质上是在给一个脆弱的时序设计打补丁。
code 换号把这套时序整个砍掉了。code 的设计目标是:它本身不含任何信息,只有微信服务端能消费它。前端拿到的 code 即使被截获,5 分钟后作废、消费一次即失效,泄漏了也无害;手机号明文只在微信机房与你的服务端之间传输,session_key 这种敏感凭证从头到尾不再需要前端参与。出错率自然也降了——前端解密时代的 -41003、padding error、乱码,在新链路里物理上不存在。
另外一层是商业与风控机制。新链路绑定了收费的手机号快速验证组件,按次计费、每个小程序账号有固定额度的免费体验次数,个人主体小程序干脆不开放该组件。批量拉号刷接口的成本被抬上去了,平台从机制上抑制了滥用,这比单纯加频控有效得多。
从时序图能看出,前端与微信之间不再有任何凭证往返,信任链的端点收窄到了服务端,这正是这次升级的核心意图。
五、迁移 checklist:灰度、无感与权限
我们整个迁移从立项到全量用了两周出头,第 1 周双跑灰度,第 2 周全量切流。下面这份清单是按实际执行顺序整理的。
| 事项 | 要点 |
|---|---|
| 灰度方案 | 服务端加开关,按 openid 尾号切 10% 流量进新链路,双跑一周比对成功率 |
| 老用户无感 | 已注册用户授权后用 purePhoneNumber 匹配既有账号,登录态直接续上,UI 不变 |
| 企业认证与权限 | 个人主体小程序没有手机号快速验证组件;接口权限需在 mp 后台提前申请 |
| 计费确认 | 组件按次计费,先核对账号体验额度,把营销部门的批量授权场景提前报备 |
| 监控告警 | 按 errcode 打点,40029 占比超过 2% 触发告警,重点盯 code 复用类问题 |
| 回滚开关 | 保留旧解密代码两周,出问题可一键切回 |
「老用户无感」这一条最值得多说一句:授权按钮交互、页面文案全部保持原样,用户感知到的只是「还是点一下就登录了」。手机号匹配账号的逻辑要处理并发注册的边界——两个设备同时授权同一号码,靠数据库的号码唯一索引兜底,冲突方提示「账号已在其他设备登录」。
六、几个容易想当然的误区
迁移过程中,我们踩过不少「想当然」的坑,有些是文档没写透,有些是旧思路惯性使然。挑四个最有代表性的展开说,每个都附上错误认知、正确做法和后果。
误区一:以为 code 和旧 encryptedData 一样需要解密。
错误认知:拿到 code 之后,第一反应是「这玩意儿是不是也要拿 session_key 解一下」,甚至有人直接套用旧的 AES 解密工具类去处理。
正确做法:code 不需要解密,也解不了。它就是一张兑换券,本身不含任何手机号信息,只有微信服务端能消费它。前端拿到后原样传给服务端,服务端调getuserphonenumber换号即可。
后果:如果真拿 code 去走解密流程,只会得到一堆乱码或报错,白白浪费排查时间。我们团队里就有人在这上面耗了半天,最后发现 code 压根不是密文。
误区二:前端把 code 缓存起来复用。
错误认知:担心用户网络不好,把 code 存进全局变量或 storage,等「登录成功后再发」,甚至放进重试队列里二次发送。
正确做法:code 的有效期约 5 分钟、只能用一次,生命周期应该压缩到「拿到即发出」。前端回调里拿到 code 立刻 POST 给服务端,不要存、不要复用、不要放进重试队列。
后果:第二次消费同一个 code 必报 40029(invalid code)。我们灰度期有一批用户就是被前端重试逻辑坑的——第一次请求超时后自动重发,第二次直接挂掉,用户看到的是「获取失败」,体验很差。
误区三:把phone_info原样透传给前端。
错误认知:服务端换号成功后,图省事直接把phone_info整个 JSON 返回给前端,让前端自己取phoneNumber展示。
正确做法:手机号属于敏感个人信息,明文应该只落在服务端日志与数据库里。前端只需要脱敏串用于回显,比如138****1234,登录态用 token 下发即可。
后果:手机号明文暴露在前端,一旦被截获或从页面缓存里翻出来,就是一次个人信息泄露事故。合规上也很危险,微信对敏感信息外泄的处罚是实打实的。
误区四:把 -1 系统繁忙当成业务错误直接透传。
错误认知:服务端收到 -1 就认为「微信挂了」,直接把错误返回给前端,让用户「稍后再试」。
正确做法:-1 是微信侧的瞬时抖动,官方文档明确建议原样重试。服务端做 3 次指数退避重试(200ms、400ms、600ms),重试期间不要改请求参数,-1 只认原样重发。
后果:我们一开始偷懒直接透传,白天高峰期成功率被拉低了一截,用户反复点授权按钮,体验和转化都受影响。改成服务端重试后基本抹平,这个坑最不值得踩。
FAQ:读者常见问题
Q1:个人主体小程序如何申请手机号快速验证组件?
个人主体小程序目前不开放手机号快速验证组件,无法申请。这是微信平台的硬性限制,与代码无关。如果业务强依赖手机号,只能走企业主体小程序,或在个人主体下改用其他身份验证方式(如微信登录 + 手动填写手机号并短信验证)。
Q2:code 过期后用户重新授权,是否需要重新登录?
不需要。code 只是换取手机号的临时凭证,与登录态无关。用户重新点一次授权按钮拿到新 code,服务端用新 code 换号成功后,直接复用原有登录态即可,无需让用户重新走一遍完整登录流程。
Q3:云开发环境下如何监控调用量?
云开发控制台的「云函数」页面可以查看每个云函数的调用次数、耗时与错误率;更细的维度建议在云函数内部自行打点,把errcode分布、40029占比等指标上报到日志服务或自定义监控,再按 errcode 设置告警阈值。
Q4:code 换号接口的免费额度用完了怎么办?
手机号快速验证组件按次计费,免费体验额度用完后会开始扣费。建议在 mp 后台提前核对账号额度,把批量授权场景(如营销活动)提前报备,避免高峰期额度耗尽导致线上授权失败。
往后看,小程序的身份类能力都在往「服务端直取」方向收敛,手机号只是走得最早的一个。还挂着旧解密链路的项目,建议趁早排期,别等线上报错那天再动手。迁移中撞到别的报错,欢迎在评论区交流,错误码和上下文贴全,基本都能对上号。
参考与延伸
- 手机号快速验证组件官方文档(前端接入)
- 手机号获取服务端接口文档(getuserphonenumber)
- access_token 获取与 stable_token 说明
微信小程序开发、getPhoneNumber、手机号快速验证、code 换号、phonenumber.getPhoneNumber、access_token、小程序登录
user-info/phone-number.html)
- access_token 获取与 stable_token 说明
微信小程序开发、getPhoneNumber、手机号快速验证、code 换号、phonenumber.getPhoneNumber、access_token、小程序登录