getPhoneNumber 旧接口停用迁移记:code 换手机号的新写法与三类报错
一、老代码是在一个周三早上集体罢工的
去年 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 回调里 -->
<button
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhone"
class="login-btn">
授权手机号登录
</button>
// 页面逻辑:回调里直接取 detail.code,不再碰 encryptedData
Page({
onGetPhone(e) {
// 用户点了「拒绝」时 detail 里没有 code,只有 errMsg
if (!e.detail.code) {
// 拒绝授权是正常用户行为,不要弹强提示打断他
wx.showToast({ title: '已取消授权', icon: 'none' });
return;
}
// code 有效期约 5 分钟,且只能消费一次,拿到立刻传后端
// 变量先存进局部作用域,避免异步过程中被二次取值
const code = 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: () => {
// 网络异常时提示稍后再试,不要在这里重发同一个 code
wx.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 模块里发放与刷新
const express = require('express');
const axios = require('axios');
const app = express();
// 解析 JSON 请求体,前端传的是 { code: 'xxx' }
app.use(express.json());
// 官方换号接口:query 上挂 access_token,body 里只放 code
const PHONE_URL = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber';
app.post('/auth/phone', async (req, res) => {
const code = (req.body || {}).code;
// 入口先挡空值,避免拿空 code 去打微信接口白白耗调用次数
if (!code) {
return res.status(400).json({ msg: 'code 缺失' });
}
try {
// token 从统一发号器取,内部带提前刷新与并发去重
// 千万不要在每个接口里各自调 getAccessToken,会互相顶掉
const token = await tokenManager.getToken();
// 微信对 -1 系统繁忙的建议是原样重试,这里做最多 3 次退避
// 重试期间不要改请求参数,-1 只认原样重发
for (let i = 0; i < 3; i++) {
const resp = await axios.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) {
// 手机号是敏感个人信息,前端只给脱敏串,明文只留服务端
const masked = phone_info.purePhoneNumber
.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
return res.json({ phoneMasked: masked, token: issueToken(masked) });
}
// 40029 说明 code 过期或已被消费,不要重试,让前端重新拉授权
if (errcode === 40029) {
return res.status(409).json({ msg: 'code 已失效,请重新授权' });
}
// 其余错误码先记日志,按 200ms 递增等待后再试
console.warn('getuserphonenumber fail', errcode, resp.data.errmsg);
await new Promise((r) => setTimeout(r, 200 * (i + 1)));
}
// 三次都失败就返回通用错误,前端提示稍后再试
return res.status(502).json({ msg: '换号失败,请稍后再试' });
} catch (err) {
// 网络层异常与业务错误分开记,方便区分是微信侧还是自己侧的问题
console.error('phone exchange error', err.message);
return res.status(500).json({ msg: '服务异常' });
}
});
云开发侧的等价写法更短,适合不想自己维护 access_token 的团队:
// 依赖:云函数环境里的 wx-server-sdk
const cloud = require('wx-server-sdk');
// 初始化时跟随当前云环境,避免写死 envId
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
// 云调用不需要 access_token,平台会用云函数身份代持凭证
// 入口同样建议判一次空,防止前端把空 code 直接丢进来
if (!event.code) {
// 与 HTTP 版保持一致的错误语义,前端好做统一处理
throw new Error('code 缺失');
}
const res = await cloud.openapi.phonenumber.getPhoneNumber({
code: event.code,
});
// 返回结构与 HTTP 接口的 phone_info 一致,可直接取 purePhoneNumber
return res.phoneInfo;
};
新旧两条链路放在一起看,差别就很直观了:
flowchart LR
subgraph OLD[旧链路 前端密文加服务端解密]
A1[用户点按钮] --> A2[wx.login 换 session_key]
A2 --> A3[回调返回 encryptedData 与 iv]
A3 --> A4[密文传给服务端]
A4 --> A5[服务端 AES 解密出手机号]
end
subgraph NEW[新链路 code 换手机号]
B1[用户点按钮] --> B2[回调直接返回 code]
B2 --> B3[code 传给服务端]
B3 --> B4[服务端调 getuserphonenumber]
B4 --> B5[微信直返明文手机号]
end
新链路里前端彻底退出密码学环节,只是个 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、乱码,在新链路里物理上不存在。
另外一层是商业与风控机制。新链路绑定了收费的手机号快速验证组件,按次计费、每个小程序账号有固定额度的免费体验次数,个人主体小程序干脆不开放该组件。批量拉号刷接口的成本被抬上去了,平台从机制上抑制了滥用,这比单纯加频控有效得多。
sequenceDiagram
participant U as 用户
participant M as 小程序前端
participant S as 业务服务端
participant W as 微信接口
U->>M: 点击手机号授权按钮
M->>M: bindgetphonenumber 回调取 detail.code
M->>S: POST /auth/phone 携带 code
S->>W: POST getuserphonenumber 携带 access_token
W-->>S: 返回 phone_info 与 watermark
S-->>M: 返回登录态 token 与脱敏手机号
M-->>U: 进入已登录态
从时序图能看出,前端与微信之间不再有任何凭证往返,信任链的端点收窄到了服务端,这正是这次升级的核心意图。
五、迁移 checklist:灰度、无感与权限
我们整个迁移从立项到全量用了两周出头,第 1 周双跑灰度,第 2 周全量切流。下面这份清单是按实际执行顺序整理的。
| 事项 | 要点 |
|---|---|
| 灰度方案 | 服务端加开关,按 openid 尾号切 10% 流量进新链路,双跑一周比对成功率 |
| 老用户无感 | 已注册用户授权后用 purePhoneNumber 匹配既有账号,登录态直接续上,UI 不变 |
| 企业认证与权限 | 个人主体小程序没有手机号快速验证组件;接口权限需在 mp 后台提前申请 |
| 计费确认 | 组件按次计费,先核对账号体验额度,把营销部门的批量授权场景提前报备 |
| 监控告警 | 按 errcode 打点,40029 占比超过 2% 触发告警,重点盯 code 复用类问题 |
| 回滚开关 | 保留旧解密代码两周,出问题可一键切回 |
「老用户无感」这一条最值得多说一句:授权按钮交互、页面文案全部保持原样,用户感知到的只是「还是点一下就登录了」。手机号匹配账号的逻辑要处理并发注册的边界——两个设备同时授权同一号码,靠数据库的号码唯一索引兜底,冲突方提示「账号已在其他设备登录」。
六、几个容易想当然的误区
一是以为 code 和旧 encryptedData 一样需要解密。不需要,也解不了,它就是一张兑换券,只有微信认。二是前端把 code 缓存起来复用,比如放进全局状态等待「登录成功后再发」,结果第二次消费必报 40029,code 的生命周期应该压缩到「拿到即发出」。三是把 phone_info 原样透传给前端。手机号属于敏感个人信息,明文应该只落在服务端日志与数据库里,前端拿到脱敏串就够了。
往后看,小程序的身份类能力都在往「服务端直取」方向收敛,手机号只是走得最早的一个。还挂着旧解密链路的项目,建议趁早排期,别等线上报错那天再动手。迁移中撞到别的报错,欢迎在评论区交流,错误码和上下文贴全,基本都能对上号。
参考与延伸
微信小程序开发、getPhoneNumber、手机号快速验证、code 换号、phonenumber.getPhoneNumber、access_token、小程序登录