☰
uniapp微信小程序获取手机号:code换号、解密兼容与避坑
2026/9/29 1:30:46 网站建设 项目流程

微信小程序里做登录,绕不开的一件事就是拿到用户的手机号。做 uniapp 这几年,我手上跑过十几个需要手机号注册的项目,从早期自己拿 encryptedData 做 AES 解密,到后来官方把整个链路改成 code 换手机号,中间踩的坑足够写一篇长文。这篇就把「uniapp 开发微信小程序,如何获取微信用户手机号」这件事拆开讲透:前端 button 怎么摆、后端两条 code 怎么串、老项目的解密代码还能不能留、以及上线后真正会让人半夜爬起来改代码的那些问题。

如果你现在正处于这几种状态,这篇基本能覆盖你的需求:刚接手一个 uniapp 项目要做手机号一键登录;手里的老项目还在跑 encryptedData 解密,担心哪天失效;接口能调通但偶尔报错不知道怎么排查;准备上架安卓市场和小程序双端,想知道哪些逻辑需要条件编译。前端我会用 Vue3 + setup 的写法,后端给 Node 的完整实现,同时把 Java 版本的关键差异点标出来,方便你直接抄。

1. 需求拆解:为什么"获取手机号"不是一行代码的事

1.1 业务场景与三条技术路线的取舍

先明确一件事:微信小程序从头到尾都不允许你直接读取用户手机号,你能拿到的只有"用户主动授权后,微信给你的一段凭证"。这个设计决定了整个链路必然是"前端拿凭证、后端换数据"的两段式结构,任何试图在前端直接解出手机号的做法都是错的,也是不安全的。

实际项目里能走的路基本三条。第一条是手机号快速验证组件,也就是button加open-type="getPhoneNumber",用户点一下,微信弹窗确认,你拿到一个 code,后端换回手机号。这是目前最主流、体验最好的方式,缺点是按次计费,而且要求小程序是非个人主体。第二条是短信验证码,用户自己输手机号,你调第三方短信服务发码校验。它的好处是不受微信接口限制、不花钱(相对便宜),坏处是转化率明显低一截,用户要切出去看短信再回来。第三条是让用户手填,只做格式校验不做真实性校验,这种一般只出现在内部工具或者测试环境。

我在做校园跑腿、社区团购这类项目时,基本都用第一种做主路径,第二种兜底。原因很直接:手机号一键登录的转化率比短信验证码高不少,尤其是首单场景,用户耐心只有几秒。但兜底路径一定要有,因为总有一部分用户会点"拒绝",或者他的微信版本、设备环境导致授权失败,这时候如果没有短信登录,你就直接把用户挡在门外了。

1.2 新旧接口的分水岭:encryptedData 和 code 的区别

这是最容易让老项目翻车的地方。早期微信给的是encryptedData+iv,前端拿到这两个值,连同wx.login得到的 code 一起传给后端;后端先用 code 调code2Session换出session_key,再用session_key当密钥、iv当初始向量,对encryptedData做 AES-128-CBC 解密,最后拿到手机号。

新版本把这一步彻底简化了:button的回调里直接给一个code,后端拿这个 code 去调getuserphonenumber接口,一次性换回手机号,全程不需要session_key,也不需要在前端碰任何加密数据。这个改动看起来只是少了一步解密,实际上解决了一个非常恶心的历史问题——session_key是会过期的,而且用户重新登录、切换账号、长时间不用都会让它失效。老代码里"解密失败"的报错,九成以上都是session_key已经不对了。

所以我的判断标准很明确:新项目一律只用 code 方式,不要为了兼容去写解密逻辑。老项目也不要急着删解密代码,先确认你的最低基础库版本,如果还在支持很旧的微信版本,那就两套并存,用返回字段判断走哪条路。判断方式很简单,回调里e.detail.code有值就走新链路,只有encryptedData就走老链路。

有一个硬性前提必须提前确认:手机号快速验证组件要求小程序主体是企业、政府、媒体或其他组织,个人主体的小程序是拿不到这个能力的。我见过有团队开发到一半才发现主体是个人,最后不得不重新注册小程序、迁移主体,整个工期往后拖了两周。这件事在项目立项阶段就要确认,别等到联调才发现。

1.3 计费与调用次数:上线前必须算清楚的一笔账

手机号快速验证组件是收费能力,按成功调用的次数计费,官方会给出每月的免费额度,具体的额度和单价调整过不止一次。我不在这里写死数字,因为写了也可能过时,你要做的是上线前打开小程序后台,在服务市场或者用量明细里确认当前的免费额度和单价,再根据你的日活估算成本。

这里有个容易被忽略的点:用户在授权弹窗上点"拒绝"是不计费的,只有真正换回手机号才算一次成功调用。但反过来说,如果用户的手机号已经在你的库里,你还每次都去调接口换一遍,那就是纯浪费。我一般的做法是,登录成功后把手机号和 openid 的绑定关系存下来,下次这个 openid 进来直接查库,只有查不到或者用户主动要求"更换手机号"时才重新走授权。

另外官方对同一用户重复授权有去重策略,具体规则以官方说明和你的后台账单为准。但你不能依赖这个去重,正确的做法还是在自己这边做幂等:同一个 openid 在短时间内多次请求,服务端应该用锁或者唯一索引挡住,避免重复扣费和脏数据。

2. uniapp 端实现:button 组件的正确打开方式

2.1 基础写法与事件对象里到底有什么

uni-app 编译到微信小程序时,button的open-type是透传的,所以写法和原生小程序几乎一致。最小可用代码长这样:

<template> <view class="login-wrap"> <!-- #ifdef MP-WEIXIN --> <button class="phone-btn" open-type="getPhoneNumber" :loading="loading" :disabled="loading" @getphonenumber="onGetPhoneNumber" >手机号一键登录</button> <!-- #endif --> <!-- #ifndef MP-WEIXIN --> <button class="phone-btn" @click="toSmsLogin">短信验证码登录</button> <!-- #endif --> </view> </template>

注意这里是@getphonenumber而不是@getPhoneNumber,uni-app 的事件名走的是全小写约定,写错了不会报错,只是永远不触发,这个坑我帮人排查过至少三次。

回调参数e.detail里最关键的字段是code。新接口下它就是你要传给后端的凭证,有效期五分钟,而且只能用一次,用过就废。如果用户点了拒绝,e.detail里不会给你 code,取而代之的是errMsg,通常是getPhoneNumber:fail user deny。这个分支必须处理,不然你的按钮会一直停在 loading 状态,用户以为卡死了。

2.2 完整登录流程:两条 code 怎么串起来

一个完整的登录流程需要两次 code 交换,很多人第一次做会绕晕,我用一句话概括:wx.login的 code 用来换 openid(你是谁),getPhoneNumber的 code 用来换手机号(你的号是多少)。两者不能混用,也不能只传一个。

import { ref } from 'vue' const loading = ref(false) const onGetPhoneNumber = async (e) => { // 用户点了拒绝,或者环境不支持 if (!e.detail || !e.detail.code) { console.warn('授权未通过:', e.detail && e.detail.errMsg) // 给用户一个更友好的引导,别只是弹个错误 uni.showToast({ title: '未授权手机号,可改用短信登录', icon: 'none' }) return } if (loading.value) return // 防重复点击 loading.value = true try { // 第一步:拿登录 code const loginRes = await uni.login({ provider: 'weixin' }) if (!loginRes.code) throw new Error('wx.login 未返回 code') // 第二步:两个 code 一起交给后端,前端不碰手机号 const res = await uni.request({ url: 'https://your-api.com/api/auth/phone-login', method: 'POST', header: { 'content-type': 'application/json' }, data: { jsCode: loginRes.code, // 换 openid / session_key phoneCode: e.detail.code // 换手机号 } }) if (res.data.code !== 0) throw new Error(res.data.msg || '登录失败') // 第三步:存业务 token,后续请求带上 uni.setStorageSync('token', res.data.token) uni.setStorageSync('userInfo', res.data.userInfo) uni.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 600) } catch (err) { console.error('手机号登录异常:', err) uni.showToast({ title: '登录失败,请重试', icon: 'none' }) } finally { loading.value = false } }

这段代码里有三个细节值得单独说。第一,uni.login的 code 和getPhoneNumber的 code 都要在五分钟内送到后端,两个都是短时效的,所以不要在中间插入耗时的操作,比如先上传头像再登录。第二,loading状态既用于按钮的视觉反馈,也用于防重复点击,disabled和loading属性都要绑上,只绑一个在部分安卓机型上会出现连点两次的情况。第三,uni.request的域名必须在小程序后台配置到 request 合法域名里,否则真机上直接请求失败,而开发者工具里勾了"不校验合法域名"就能过,这个差异让很多人以为是后端问题。

2.3 样式与交互的坑:button 默认样式和弹层层级

uni-app 的button组件自带一套默认样式,圆角、边框、高度都有预设值。你直接加背景色会发现边框还在,那是::after伪元素在作怪。我的处理方式是把默认样式清干净再自己写:

.phone-btn { width: 100%; height: 88rpx; line-height: 88rpx; font-size: 32rpx; color: #fff; background: linear-gradient(90deg, #07c160, #05a04d); border-radius: 44rpx; border: none; } .phone-btn::after { border: none; }

还有两个交互层面的坑。一个是层级问题,如果你的登录弹层用了position: fixed加高z-index,在部分安卓机上button的原生点击区域可能被遮挡,表现是按钮看得见点不动。我一般会在弹层出现时用v-if控制真实的 button 渲染,而不是靠visibility隐藏,能避开大部分诡异现象。另一个是点击热区,微信要求触发授权的必须是一次真实的用户点击,如果你在@tap里用代码去模拟触发,或者用catchtap把事件拦掉了,授权是不会弹出来的。

2.4 授权结果缓存与二次进入的降级处理

真实业务里,用户第二次打开小程序时不应该再弹一次授权框。正确做法是本地存一份登录态,进页面先判断:有 token 且未过期就直接进首页,没有才展示登录按钮。但要注意 token 的有效期不能设太长,我一般业务 token 给 7 天,同时后端存一个 refresh 机制,过期后静默续期,续期失败再让用户重新授权。

还有一种情况需要专门处理:用户之前在别的设备或者别的微信号上登录过,换了账号进来,本地缓存的 openid 和当前wx.login拿到的 openid 不一致。这时候如果直接复用旧 token,就会出现"看到的是别人的数据"这种严重问题。我的做法是在启动时先调一次wx.login,把 code 交给后端换 openid,和本地缓存的 openid 比对,不一致就清空本地存储,重新走登录流程。这个校验成本很低,但能挡住一类很危险的数据串号问题。

3. 服务端换取手机号:code 换手机号的完整链路

3.1 access_token 的缓存策略决定了接口稳不稳

先讲一个很多人栽跟头的地方。getuserphonenumber接口需要access_token,而access_token有两个特性:有效期 7200 秒,以及全局唯一——新获取的会把旧的顶掉。如果你的服务部署了多个实例,每个实例各自去拿 token,就会出现 A 实例刚拿到,B 实例又拿一次,A 手里的立刻失效,表现为接口随机报 40001 或 42001,而且是那种"本地测好好的,一上生产就抽风"的随机报错。

正确的做法是把 token 集中管理。我的标准方案是 Redis 存一份,key 带上 appid,value 存 token 和过期时间戳,所有实例只读这一份。刷新时机设在过期前 5 分钟,并且加一个分布式锁,保证同一时刻只有一个实例去调微信接口:

// 简化示意,生产环境请补上异常重试和锁超时 async function getAccessToken(redis) { const cacheKey = `wx:access_token:${process.env.WX_APPID}` const cached = await redis.get(cacheKey) if (cached) return cached const res = await axios.get('https://api.weixin.qq.com/cgi-bin/token', { params: { grant_type: 'client_credential', appid: process.env.WX_APPID, secret: process.env.WX_SECRET } }) if (res.data.errcode) { throw new Error(`获取 access_token 失败: ${res.data.errcode} ${res.data.errmsg}`) } // 提前 300 秒过期,避免边界时刻拿到即将失效的 token await redis.set(cacheKey, res.data.access_token, 'EX', res.data.expires_in - 300) return res.data.access_token }

appsecret这个东西只能放在服务端,绝对不能出现在小程序代码里。我见过有人把它写在 manifest 里然后打包发布,虽然 uniapp 打包后代码是压缩的,但抓包和反编译都能拿到,等于把整个小程序的权限交出去了。一旦泄露,要去后台立刻重置。

3.2 两条链路的服务端实现

后端要处理两件事:用jsCode换 openid,用phoneCode换手机号。前者用的是code2Session,后者用的是getuserphonenumber:

const axios = require('axios') // 用 jsCode 换 openid / session_key / unionid async function code2Session(jsCode) { const res = await axios.get('https://api.weixin.qq.com/sns/jscode2session', { params: { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: jsCode, grant_type: 'authorization_code' } }) if (res.data.errcode) { throw new Error(`code2Session 失败: ${res.data.errcode} ${res.data.errmsg}`) } return res.data // { openid, session_key, unionid } } // 用 phoneCode 换手机号 async function getPhoneByCode(phoneCode, accessToken) { const res = await axios.post( `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`, { code: phoneCode }, { headers: { 'content-type': 'application/json' } } ) if (res.data.errcode !== 0) { throw new Error(`换取手机号失败: ${res.data.errcode} ${res.data.errmsg}`) } return res.data.phone_info // { phoneNumber, purePhoneNumber, countryCode, watermark } }

返回结构里有四个字段要认清。phoneNumber是带区号的完整号码,比如+86 138xxxx;purePhoneNumber是纯号码,一般入库用这个;countryCode是国家码;watermark里带着appid和时间戳,这个 watermark 必须校验,确认appid是你自己的,防止有人拿别的小程序的 code 来撞你的接口。

controller 层组装一下:

async function phoneLogin(req, res) { const { jsCode, phoneCode } = req.body if (!jsCode || !phoneCode) { return res.json({ code: 400, msg: '参数缺失' }) } // 1. 换 openid const session = await code2Session(jsCode) // 2. 换手机号 const token = await getAccessToken(redis) const phoneInfo = await getPhoneByCode(phoneCode, token) // 3. 校验 watermark,这一步别省 if (phoneInfo.watermark.appid !== process.env.WX_APPID) { return res.json({ code: 403, msg: '来源校验失败' }) } // 4. 入库 + 签发业务 token,注意用 openid 做幂等 const user = await userService.upsertByOpenid(session.openid, { phone: phoneInfo.purePhoneNumber, countryCode: phoneInfo.countryCode, unionid: session.unionid || null }) return res.json({ code: 0, token: signJwt({ uid: user.id }), userInfo: { id: user.id, phone: maskPhone(user.phone) } }) }

返回给前端的手机号我习惯做脱敏,138****8888这种形式,前端展示用脱敏的,需要完整号码的场景(比如调用第三方物流接口)走服务端内部调用。这样即使前端日志被打印,也不会泄露完整号码。

如果你的后端是 Java,逻辑完全一样,只是把 HTTP 调用换成RestTemplate或WebClient,JSON 解析用 Jackson,注意getuserphonenumber是POST + JSON body,不是 GET 拼参数,这一点和code2Session不一样,搞混了会一直报参数错误。另外 Java 项目里建议把 access_token 的刷新做成定时任务 + 双重检查,别在业务请求里同步等待刷新,高并发下会拖慢响应。

3.3 unionid 与多端账号打通

如果你的项目除了微信小程序还有公众号、App、H5,那就必须关注unionid。同一个用户在同一个开放平台账号下的不同应用里,openid是不同的,但unionid相同,这是打通账号的唯一可靠依据。

前提是你的小程序和公众号绑定在同一个开放平台账号下,否则code2Session返回里根本不会有unionid字段。我踩过一次坑:账号体系按 openid 做的,后来要接入公众号登录,发现同一个人的数据分裂成两条,不得不写脚本按手机号做合并。所以如果你有一点点多端规划,从一开始就把unionid存下来,哪怕暂时用不到,成本几乎为零,后面能省掉大麻烦。

4. 老项目兼容:encryptedData 解密还在跑怎么办

4.1 AES-128-CBC 解密的原理与三个失败原因

老链路的解密逻辑,现在还有不少项目在跑,尤其是那些基础库版本卡得比较低的小程序。原理不复杂:session_key做密钥,iv做初始向量,encryptedData做密文,算法是 AES-128-CBC,填充方式是 PKCS#7。Node 的实现大概是这样:

const crypto = require('crypto') function decryptPhone(sessionKey, encryptedData, iv, appid) { const key = Buffer.from(sessionKey, 'base64') const ivBuf = Buffer.from(iv, 'base64') const dataBuf = Buffer.from(encryptedData, 'base64') const decipher = crypto.createDecipheriv('aes-128-cbc', key, ivBuf) decipher.setAutoPadding(true) let decoded = decipher.update(dataBuf, undefined, 'utf8') decoded += decipher.final('utf8') const result = JSON.parse(decoded) if (result.watermark.appid !== appid) { throw new Error('watermark 校验不通过') } return result // { phoneNumber, purePhoneNumber, countryCode, watermark } }

解密失败基本逃不出三个原因。第一,session_key过期或对不上,这是绝大多数情况。session_key必须是紧跟着本次wx.login的 code 换出来的那一份,如果你缓存了旧的session_key去解密新拿到的encryptedData,必然失败。第二,Base64 解码出问题,有些框架在处理+和/时会做 URL 编码转换,导致密钥长度不对,AES-128 要求密钥正好 16 字节,长度不对会直接抛异常。第三,iv用错,iv是每次授权都变的,不能复用。

4.2 新老接口并存的判断逻辑与迁移路线

如果你现在必须两套都保留,服务端的处理逻辑应该以phoneCode优先:

async function resolvePhone(payload, sessionKey) { // 新接口优先,有 code 就不走解密 if (payload.phoneCode) { const token = await getAccessToken(redis) const info = await getPhoneByCode(payload.phoneCode, token) return { phone: info.purePhoneNumber, source: 'code' } } // 兜底走老解密 if (payload.encryptedData && payload.iv && sessionKey) { const info = decryptPhone(sessionKey, payload.encryptedData, payload.iv, process.env.WX_APPID) return { phone: info.purePhoneNumber, source: 'encrypted' } } throw new Error('无有效凭证') }

迁移路线我建议分三步走。第一步,服务端先支持新接口,前端不动,观察日志里source字段的分布。第二步,前端升级button的绑定方式,同时保留老分支,灰度一部分用户。第三步,等到老分支的调用量降到接近零——一般是几个版本迭代之后——再删掉解密代码和session_key的缓存逻辑。顺序不能反,先删前端会导致没升级的用户直接登不进去。

还有一点,session_key的缓存本身是个安全隐患,它相当于用户数据的钥匙。如果确实要缓存,务必加密存储、设置短过期时间,并且和用户 openid 严格绑定。新接口上线之后,这部分缓存的必要性就大大降低了,能删就尽早删。

5. 常见问题与排查技巧实录

5.1 错误码速查表

调这两个接口,报错信息其实都挺明确的,问题是很多人不看errmsg只看有没有报错。下面这张表是我这几年攒下来的,基本覆盖了九成以上的场景:

错误码出现环节典型原因处理方式
40029code2Sessioncode 已使用或已过期重新wx.login拿新 code,别复用
40013全部appid 配置错误核对 manifest 和后端环境变量
40125全部appsecret 错误后台重置密钥并更新服务端配置
40001换取手机号access_token 无效检查是否多实例各自刷新,改为集中缓存
42001换取手机号access_token 过期刷新逻辑是否失效,注意提前 5 分钟续期
48001换取手机号接口未授权确认小程序已认证、组件已开通
45011换取手机号触发频率限制加节流,同一用户短时间不要重复调
47001换取手机号请求体格式错误必须是 POST + JSON,别用 GET 拼参
-1全部微信侧系统繁忙加指数退避重试,一般 1 到 2 次即可
-41003老解密解密失败九成是 session_key 不匹配

用这张表的时候有个技巧:把errcode和errmsg原样打到日志里,别自己包装成"登录失败"。我有一次排查线上问题,日志里只写了"登录失败",翻了两个小时才定位到是 45011 频率限制,如果当初把原始错误打出来,五分钟就解决了。

5.2 实测踩过的坑与避坑清单

第一个坑,开发者工具和真机行为不一致。开发者工具里点授权按钮,会弹一个模拟弹窗,返回一个固定的测试手机号,而且不走计费。很多人在工具里测通了就以为没问题,一到真机发现 code 换不出手机号。原因是工具里用的是模拟数据,真实的access_token和接口调用并没有真正发生。我的建议是,第一天就用真机联调,并且在体验版上完整走一遍,别等到提审前才测。

第二个坑,wx.login的 code 被复用。有人在页面onLoad里调一次wx.login缓存起来,等用户点授权按钮时直接拿缓存里的 code 去换 openid。这在用户停留时间短的时候没问题,但只要超过五分钟,code 就失效了,报 40029。正确做法是在点击授权的那一刻才调wx.login,两个 code 一起发出去。

第三个坑,按钮被父元素的事件拦截。有些 UI 库的cell或者自定义弹层会在父级绑@tap,冒泡上去把事件吃掉了;还有人用.stop修饰符,结果授权弹窗根本不出来。排查方法很简单,把 button 单独放到一个干净的空页面里点一次,能弹出来就是被拦了。

第四个坑,重复扣费。前面提过,用户重复点按钮、网络超时重试、前端没做 loading 防抖,都会造成多次调用。我在服务端加了一层基于 openid 的短时锁,同一个 openid 在 3 秒内只允许一次成功换取,剩下的返回上一次的结果。这个小改动上线后,手机号相关的调用量下降了差不多两成,成本是实打实省下来的。

第五个坑,基础库版本没设。手机号 code 方式需要一定的基础库版本支持,如果你的最低基础库版本设得太低,部分老版本微信用户会走到老分支甚至是异常分支。我的做法是在 manifest.json 里配好 appid 和相关的 mp-weixin 配置,同时在微信公众平台后台把最低基础库版本设到一个合理的档位,比如 2.21.2 以上,然后在代码里用wx.canIUse做一次能力检测,不支持就降级到短信登录。这样既保证了新用户体验,也不会把老设备用户彻底挡住。

第六个坑,安卓打包后行为差异。有些团队是 uniapp 一套代码同时发微信小程序和安卓 App,安卓那边走的是原生授权或者短信登录,和小程序的逻辑完全不同,必须用#ifdef MP-WEIXIN隔开。我见过有人把open-type="getPhoneNumber"直接写在公共模板里,打包安卓时这个属性被忽略,按钮点上去毫无反应,查了半天代码。

最后一个心得是关于错误提示的。用户点了拒绝,不要弹"登录失败",那会让人以为程序坏了。提示语应该是"未获取到手机号,可以改用短信验证码登录",并且顺手把短信入口露出来。这一点小小的改动,在我们一个项目里把登录页的跳出率降了将近十个百分点,比优化任何技术细节都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询