
1. 三代方案演进与 code 模式为什么成了唯一选择做过微信小程序、又用uniapp写过登录链路的人多半都被获取微信用户手机号这件事折腾过。早几年一行getPhoneNumber加上服务端 AES 解密就完事后来官方一路收紧encryptedData 方案下线、组件开始计费、隐私协议强制弹窗每一步都在改。标题里说的最新指的就是当下这套前端拿 code、后端换手机号的流程。它本身不复杂麻烦的是散落在账号资质、隐私配置、基础库版本、服务端 token 缓存这几处的细节任何一个没对齐真机上就是一句getPhoneNumber:fail。这篇总结适合三类人刚接触小程序登录链路的新手、从旧方案迁移过来的老项目维护者以及在 uniapp 里同时要兼顾 App 和 H5 的多端开发者。先说清楚它到底解决什么问题。微信手机号不是普通表单字段它绑定在用户微信号上小程序拿不到用户的手机号明文只能拿到一个由微信签发的凭证再由你的服务器去微信侧换回号码。这个设计的核心是数据不落地到前端避免前端伪造。所以整条链路被拆成两半前端只负责在用户主动点击时拿到一个有效期极短的code后端拿code去调微信的接口换真实号码。1.1 从 encryptedData 到 code 换取接口到底改了什么第一代方案是button open-typegetPhoneNumber的事件里直接带回encryptedData和iv服务端用session_key做 AES-128-CBC 解密。这套东西的问题在于session_key必须和wx.login的code配套一旦两次登录之间混用或者服务端缓存了过期的session_key解密就会失败报解密失败的概率高得离谱。而且session_key存在多端并发登录被刷新、后台多实例部署缓存不一致等一堆坑。第二代是把session_key交给微信侧托管前端getPhoneNumber直接返回明文包后端只做校验水印。这个过渡期很短很多项目根本没赶上。第三代就是现在这套前端getPhoneNumber事件只返回一个code后端用access_token调wxa/business/getuserphonenumber拿到phone_info。session_key彻底退出手机号流程前端不再碰任何加密数据。这个改动把最不稳定的环节砍掉了也顺带把调用量统计、计费、风控都收拢到服务端接口上。理解这条演进线很重要因为你搜到的很多老教程还在讲encryptedData照着抄必然跑不通。判断方法很简单看文章里的代码有没有wxa/business/getuserphonenumber这个路径没有的直接跳过。1.2 为什么现在绕不过 code 模式有人会问能不能前端直接拿到手机号不行。微信侧的接口只对access_token开放access_token是用AppID和AppSecret换的而AppSecret绝对不能出现在小程序代码里——小程序包是可以被解出来的一旦泄露别人能拿你的access_token调所有服务端接口包括发消息、改配置。另一层原因是计费和风控。手机号验证组件是按次计费的如果允许前端直连官方没法统计。现在所有调用都要带access_token微信侧能精确到每个小程序账号的调用次数也能对异常高频调用做限制。再一层是隐私合规。用户点允许这个动作必须发生在微信的组件上由微信记录授权行为。你用自绘弹窗骗用户勾选是不合规的审核通不过。所以open-typegetPhoneNumber这个按钮不能替换成普通view加点击事件必须用button组件。1.3 各阶段读者怎么快速上手如果你是完全没做过这块的新手建议按顺序看完第 2 章到第 4 章把环境配置和前后端链路都跑一遍再回来看第 5 章的报错排查那时候你才有体感。如果你是老项目迁移直接跳到第 2 章的资质与隐私部分再对照第 4 章把服务端接口换掉前端改动其实很小。如果你只关心 uniapp 里的写法差异第 3 章和第 6 章是重点。整篇文章里的参数、错误码、配置项我都会给出取值和判断依据尽量让你少开几个文档页。2. 前置准备资质、隐私指引、付费额度与基础库这一章是踩坑密度最高的地方。很多人代码写对了结果工具里能跑、真机上失败或者第一次调用正常、第二次报权限错误八成是这里没配全。我按从账号到工程的顺序捋一遍。2.1 主体资质与组件开通手机号快速验证组件对主体有要求。个人主体的小程序通常用不了这个能力需要企业、政府、媒体或其他组织主体。这不是技术限制是能力开通的准入条件。判断方式登录小程序管理后台进功能栏目看有没有手机号快速验证或手机号实时验证的入口。如果有点进去开通即可如果压根看不到条目说明当前主体不支持。开通时会让你确认计费规则。官方给过每个账号一定量的免费额度超出后按次收费单价在几分钱这个量级。具体数字和免费额度的调整比较频繁我这里不给死数你以后台功能页面显示的报价为准。有一点要提醒两种组件的计费和使用场景不同。快速验证组件是一键获取用户微信绑定的手机号用户不用输实时验证组件是让用户填手机号再发短信验证码适合用户想用非微信绑定号的场景。选错组件会导致产品流程和预算都对不上。开通后建议立即在后台把用量统计页面收藏一下。上线初期调用量异常增高通常不是用户多而是前端在循环里调接口或者用户重复点击没做防抖。2.2 隐私保护指引配置细节从《小程序用户隐私保护指引》强制生效之后涉及收集用户信息的接口都要在指引里声明。手机号属于明确的个人信息必须声明。路径在管理后台的设置 - 服务内容声明 - 用户隐私保护指引。配置时有两个常见误区。第一个是只声明了手机号没有勾选对应的信息类型。指引里的信息类型是复选的要选到手机号这一项才能和getPhoneNumber接口对上。第二个是版本更新后没重新审核指引改了内容但没提交线上还是旧版本接口就会提示隐私相关错误。审核通过之后前端还需要处理用户的授权动作。较新的基础库提供了隐私授权相关的 API思路是用户点击手机号按钮时如果还没有同意过隐私协议会先触发一个需要授权的回调你在回调里展示自己的隐私弹窗用户点同意后再继续原流程。这里的关键是弹窗里的同意按钮要和button组件的open-type关联不能随便用一个view的点击事件去 resolve否则微信侧不认。// uniapp 中处理隐私授权放在页面 onLoad 或 app 启动时 // #ifdef MP-WEIXIN if (wx.onNeedPrivacyAuthorization) { wx.onNeedPrivacyAuthorization((resolve, eventInfo) { // eventInfo.referrer 可以知道是哪个接口触发的 // 这里把自定义弹窗显示出来用户点击同意后再调用 // resolve({ event: agree, buttonId: agree-btn }) // 其中 agree-btn 是弹窗里 button 组件的 id privacyResolve resolve }) } // #endifbuttonId这个字段是很多人漏掉的漏了的话用户点了同意接口还是走不下去表现就是点了没反应。原因是微信需要通过这个 id 定位用户实际点击的那个按钮来确认授权行为真实发生。2.3 uniapp 工程与 manifest.json 配置在 uniapp 项目里微信小程序的相关配置统一在manifest.json的mp-weixin节点下。最基础的是appid必须和你后台的AppID一致否则真机上拿到的code换不出东西报 appid 不匹配。{ mp-weixin: { appid: wx1234567890abcdef, setting: { urlCheck: false, es6: true, postcss: true, minified: true }, usingComponents: true, libVersion: 3.5.5 } }urlCheck关掉只用于开发阶段方便你连本地后端。上线前一定要打开否则接口域名没配白名单线上会直接请求失败。libVersion决定使用哪个基础库版本手机号相关能力在 2.21.2 之后才支持 code 模式建议直接写一个较新的稳定版本避免因为版本过低出现接口不存在的情况。usingComponents这个开关也值得说一句。开启后 uniapp 会走自定义组件的编译模式性能更好但如果你项目里引了老的第三方小程序组件可能出现兼容问题迁移时留意一下。2.4 基础库版本与开发者工具的处理基础库版本有两个地方设置一是manifest.json二是小程序后台的设置 - 基本设置 - 基础库最低版本。线上用户实际用的是哪个版本取决于后台配置和用户微信客户端版本。如果你用了较新的隐私 API而后台配置的最低版本很低低版本用户会直接报方法不存在。开发者工具这边有个必须知道的点手机号功能在开发者工具里拿到的是模拟数据不能当真机验证。工具里点按钮errMsg可能是ok但code换出来的号码是假的或者干脆换不出来。真机调试时用的微信号必须绑定了手机号否则会出现用户在真机上点按钮弹窗里没有任何可选的手机号直接失败。工具的调试基础库可以在详情 - 本地设置里切换。排查问题时把调试基础库调到和线上最低版本一致能复现出很多高版本看不到的问题。这个习惯能帮你提前发现线上用户点了没反应这类只在低版本出现的故障。3. uniapp 前端实战button 写法与事件处理前端这部分代码量不大但写好写坏差别明显。写坏的表现是用户拒绝一次之后就再也弹不出授权、点击后按钮有默认灰边框、失败分支没处理导致页面卡死。下面按最小可用、事件解析、流程串联、样式细节四个角度拆。3.1 最小可用代码核心就是一个buttonopen-type设为getPhoneNumber然后监听事件。在 uniapp 里 Vue2 和 Vue3 的写法略有差异但组件属性名是一样的。template view classlogin-wrap button classphone-btn open-typegetPhoneNumber getphonenumberonGetPhoneNumber erroronGetPhoneNumberError 微信手机号一键登录 /button /view /template script setup import { ref } from vue const loading ref(false) const onGetPhoneNumber async (e) { // e.detail 里包含 errMsg 和 code if (e.detail.errMsg ! getPhoneNumber:ok) { // 用户拒绝、取消、无权限都会走到这里 handleReject(e.detail.errMsg) return } if (loading.value) return loading.value true try { const res await uni.request({ url: https://your-api.com/api/wx/phone, method: POST, data: { code: e.detail.code } }) // 后端返回自己的登录态和手机号前端只存 token } finally { loading.value false } } const handleReject (errMsg) { // 区分拒绝和失败给不同文案 } /script注意getphonenumber全小写这是 uniapp 编译到小程序后的规范事件名。写成getPhoneNumber在部分版本里也能生效但别赌老老实实全小写。Vue3 的script setup里不需要methods包裹直接定义函数即可。3.2 事件对象的字段与失败分支事件对象的detail结构随版本有过变化最稳定的两个字段是errMsg和code。errMsg为getPhoneNumber:ok时code才有值且这个code只能用一次、有效期大约五分钟。失败时的errMsg常见取值需要分别处理errMsg 取值含义建议处理getPhoneNumber:fail user deny用户在弹窗里点了拒绝停留在当前页给出可再次点击的提示getPhoneNumber:fail user cancel用户点了取消或关闭弹窗同拒绝不做报错弹窗getPhoneNumber:fail no permission未开通组件或主体不支持上报日志同时降级到手机号验证码登录getPhoneNumber:fail 其他多为配置或基础库问题记录完整 errMsg便于排查这里有个产品层面的经验用户拒绝一次之后不要立刻再弹也不要把按钮置灰锁死。正确的做法是保留按钮可点同时提供一个手机号验证码登录的备选入口。原因很好理解微信的授权弹窗是系统级的用户拒绝往往是一时犹豫直接封死会让一部分本来能转化的用户流失。备选入口能兜住这部分人。error事件也不能省。有些异常不是走getphonenumber回来的而是组件层面的错误监听了error才能拿到。两者一起监听覆盖才完整。3.3 把 uni.login 和手机号流程串起来很多人会混淆两个code一个是uni.login拿到的登录code用来换openid和session_key另一个是getPhoneNumber拿到的手机号code用来换手机号。这两个 code 不能混用混了就是code 无效。典型的正确顺序是页面加载时先调uni.login拿到登录code发给后端换openid这是静默的不需要用户点。用户点手机号按钮拿到手机号code。把手机号code发给后端后端换回手机号。后端用openid找到或创建用户把手机号写入返回自己的登录态 token。也可以把两步合并用户点了手机号按钮之后前端先uni.login再拿手机号code两个一起发给后端。这样少一次请求但要注意顺序uni.login必须在用户点击的调用栈里尽早执行避免异步等待过程中code过期。const onGetPhoneNumber async (e) { if (e.detail.errMsg ! getPhoneNumber:ok) return handleReject(e.detail.errMsg) const [loginRes] await Promise.all([ new Promise((resolve, reject) { uni.login({ provider: weixin, success: resolve, fail: reject }) }) ]) await uni.request({ url: https://your-api.com/api/wx/bindPhone, method: POST, data: { loginCode: loginRes.code, phoneCode: e.detail.code } }) }为什么后端要同时拿到loginCode和phoneCode因为phoneCode换回的phone_info里虽然有水印 appid但不包含openid。你得靠loginCode换openid才能知道这个手机号该绑到哪个用户身上。两个 code 各司其职缺一不可。3.4 样式与交互细节uniapp 里button组件自带默认样式白色背景、圆角、灰色边框跟设计稿基本都不一致。要去掉边框用伪元素覆盖.phone-btn { background-color: #07c160; color: #fff; border-radius: 8rpx; font-size: 32rpx; line-height: 88rpx; height: 88rpx; } .phone-btn::after { border: none; }::after那一条是必须的微信小程序的button边框是画在伪元素上的不覆盖就一直在。这个坑几乎每个新项目都会踩一次。另一个细节是 loading 状态。用户点一次按钮如果后端响应慢用户可能连点三四次每次都触发一次手机号验证这就是在花钱。所以进事件后第一件事是做重复提交拦截用一个标志位或者禁用按钮。注意是逻辑层面的拦截不是把按钮disabled掉——disabled会让open-type失效用户想再点也点不了体验反而差。再就是按钮的文案和位置。手机号授权按钮通常放在登录页最显眼的位置但如果你的小程序同时有 App 端和 H5 端别忘了用条件编译把这段按钮只留在小程序端。App 端获取手机号是另一套逻辑直接用系统权限和运营商能力写在一起会编译报错。4. 服务端落地token 获取、缓存与手机号换取前端只负责递code真正的工作量在后端。这一章是整条链路的重点也是为什么我本地能跑线上不行最常见的源头。4.1 stable_token 与普通 token 的取舍微信的服务端接口都要带access_token。老接口是cgi-bin/token返回的 token 有效期 7200 秒且新获取会让旧 token 提前失效这个特性在多个服务实例并发刷新时非常危险A 实例刚刷新完B 实例又刷一次A 手里的 token 立刻失效线上表现为随机的 40001。后来官方提供了cgi-bin/stable_token接口支持force_refresh参数。默认不强制刷新时微信会返回同一个有效 token不会因为调用而让旧的失效。这个接口就是为多实例部署设计的。新项目直接用 stable_token不要用老的 token 接口。// Node.js 获取 stable_token const APPID process.env.WX_APPID const SECRET process.env.WX_SECRET async function fetchStableToken() { const res await fetch(https://api.weixin.qq.com/cgi-bin/stable_token, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ grant_type: client_credential, appid: APPID, secret: SECRET, force_refresh: false }) }) const data await res.json() if (data.errcode) { throw new Error(token 获取失败: ${data.errcode} ${data.errmsg}) } return data.access_token }force_refresh保持false只有在明确知道 token 失效时才传true。别每次调用都传true那等于退回到老接口的行为。4.2 多实例下的 token 缓存策略即使用了 stable_token也不该每个请求都去调一次微信接口。一来慢二来有调用频率限制。正确做法是缓存缓存的位置取决于你的部署形态。单实例应用进程内存缓存即可记录 token 和获取时间过期前五分钟刷新。多实例内存缓存各自为政虽然 stable_token 不会互相踢掉但会造成多次无谓请求。更稳的是用 Redis 做一层共享缓存加一个分布式锁保证同一时刻只有一个实例去刷新。async function getAccessToken(redis) { const cacheKey wx:access_token const cached await redis.get(cacheKey) if (cached) return cached // 加锁避免并发刷新 const lockKey wx:access_token:lock const locked await redis.set(lockKey, 1, NX, EX, 10) if (!locked) { // 没抢到锁等一会儿再读缓存 await new Promise(r setTimeout(r, 300)) return redis.get(cacheKey) } try { const token await fetchStableToken() // 提前 300 秒过期留出缓冲 await redis.set(cacheKey, token, EX, 7200 - 300) return token } finally { await redis.del(lockKey) } }EX设成 6900 秒比微信给的 7200 秒少 300 秒。这个缓冲是必要的避免刚好卡在过期边界上请求发出去时 token 还有效到微信那边就过期了。这类问题在低并发时基本遇不到一到高峰期就冒出来很难复现。4.3 调用 getuserphonenumber 换手机号拿到 token 之后调wxa/business/getuserphonenumber把前端给的code放进 body。async function getPhoneNumber(code, accessToken) { const url https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token${accessToken} const res await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code }) }) const data await res.json() if (data.errcode ! 0) { // 根据错误码做不同处理见第 5 章 throw new Error(手机号获取失败: ${data.errcode} ${data.errmsg}) } return data.phone_info }返回的phone_info结构大致是这样的{ phoneNumber: 13800000000, purePhoneNumber: 13800000000, countryCode: 86, watermark: { timestamp: 1710000000, appid: wx1234567890abcdef } }phoneNumber带国际区号前缀purePhoneNumber是纯号码。国内业务一般存purePhoneNumber或者两个都存展示用带前缀的。watermark.appid一定要校验必须等于你自己的AppID。虽然这个接口的返回是微信直接给的理论上不会被伪造但校验一下成本极低属于安全编码的基本习惯。watermark.timestamp也可以拿来做时间校验判断这个 code 是不是很早就签发的。不过微信侧对 code 本身有五分钟有效期重复使用会直接报错所以时间校验更多是防御性手段。4.4 频率限制、幂等与安全getuserphonenumber接口有频率限制超过会返回 45011 之类的错误。正常情况下一个用户点一次按钮调一次不会触发。真正会触发的是两种场景一是前端没做防抖用户连点二是脚本刷接口。防刷的思路是在服务端对openid或 IP 做限流比如同一openid十秒内只允许调三次。别忘了code是一次性的重复用同一个 code 会直接失败所以即便有人拿到历史code也换不出手机号这层保护微信已经帮你做了。幂等方面同一个用户重复绑定手机号是常见操作。设计上应该允许覆盖更新但记录变更时间。如果业务上手机号具有唯一性比如一个手机号只能注册一个账号那就要在写库时做唯一索引捕获冲突后给出明确提示而不是抛一个数据库错误给用户看。最后提醒一句手机号是敏感个人信息日志里不要打明文。调试阶段图方便直接console.log(phone_info)上线忘了删日志系统里就存了一堆真实号码。打日志时做掩码中间四位替换成星号或者只记录一条手机号换取成功的标记。5. 报错排查实战与常见问题真机上的报错信息往往很含糊一句fail就要靠经验去猜。这一章把高频问题整理出来配上排查路径。5.1 错误码与现象对照表先看服务端返回的错误码这是最明确的线索。错误码含义常见原因40001access_token 无效token 过期、被其他请求挤掉、缓存没更新40029code 无效code 被用过、超过五分钟、传错字段45011频率限制短时间内调用过于频繁48001接口未授权组件没开通、主体不支持-1系统繁忙微信侧偶发重试即可再看前端事件里的errMsg前面 3.2 已经列过。把两端信息对上基本能定位到问题。我遇到最多的是 40001 和 48001 两类。40001 九成是 token 缓存问题检查缓存是否用了共享存储、刷新逻辑有没有并发漏洞。48001 基本是资质或配置问题去后台看组件开通状态。5.2 开发者工具与真机的差异工具里能不能换来手机号不能当真。工具里拿到的是模拟数据code也是模拟的走服务端接口可能返回一些奇怪的结果。所以手机号相关的验证必须真机。真机调试还有两个高频差异点。一是基础库版本工具默认用的版本可能比用户手机上的高你用了新 API用户那边不存在直接报错。二是隐私协议状态工具里可能已经默认授权过真机上是首次会触发隐私弹窗流程。这两个差异导致的现象都是工具能用真机不能用。一个实用的排查习惯准备两台手机一台是主力机一台是老一点的备用机微信版本尽量拉大差距。新功能在老设备上跑一遍能提前发现兼容问题。5.3 隐私协议引发的失败症状是用户点了按钮弹窗也弹了用户点了同意但流程走不下去errMsg是个看不出原因的失败。这种情况先查隐私授权。排查步骤确认后台的隐私保护指引已配置且审核通过信息类型勾选了手机号。确认自定义隐私弹窗的同意按钮带id且resolve里的buttonId和它一致。确认onNeedPrivacyAuthorization是在页面加载早期注册的不是用户点击后才注册。清理小程序缓存重新进模拟首次授权流程。还有一层是用户之前拒绝过。微信会记住用户的拒绝状态吗不会永久记但如果用户在短时间内反复拒绝弹窗行为可能变得不稳定。测试时建议用没测过的微信号避免账号状态干扰。5.4 几个容易被忽略的细节getPhoneNumber的code有效期五分钟这不是从你收到开始算是从微信签发开始算。如果你在事件回调里做了比较重的异步操作才发请求比如先上传图片、先请求别的接口可能就超时了。所以拿到 code 后应该尽快发出去。另一件事是 uniapp 编译到小程序后的条件编译。如果你在 App 端也写了同名方法记得用#ifdef MP-WEIXIN包起来否则 App 编译时会因为找不到getPhoneNumber事件而报错。条件编译的注释块要不就成对出现要不就整段包住中间夹一半的常见写法很容易出错。最后是测试账号。微信提供体验版和开发版测试手机号组件建议在体验版上测最接近线上环境同时能拿到真实计费数据。开发版也可以但有些能力的表现和线上有出入。6. 工程化封装与上线前的检查清单链路跑通只是第一步真正省事的是把它封装好别每个登录页都复制一遍。这一章讲怎么组织代码以及上线前该逐项确认什么。6.1 封装成可复用的 composableVue3 项目里把登录逻辑抽成一个 hook页面里只调一个方法。// composables/useWxPhoneLogin.js import { ref } from vue export function useWxPhoneLogin(apiBase) { const loading ref(false) const errorMsg ref() const login async (phoneCode) { if (loading.value) return null loading.value true errorMsg.value try { const loginRes await new Promise((resolve, reject) { uni.login({ provider: weixin, success: resolve, fail: reject }) }) const res await uni.request({ url: ${apiBase}/api/wx/bindPhone, method: POST, data: { loginCode: loginRes.code, phoneCode } }) if (res.data.code ! 0) throw new Error(res.data.msg) uni.setStorageSync(token, res.data.data.token) return res.data.data } catch (err) { errorMsg.value err.message || 登录失败 return null } finally { loading.value false } } return { loading, errorMsg, login } }页面里用起来就很干净button open-typegetPhoneNumber getphonenumberasync (e) { if (e.detail.errMsg ! getPhoneNumber:ok) return const r await login(e.detail.code) if (r) uni.reLaunch({ url: /pages/home/index }) } 一键登录/button这样做的好处是登录逻辑只有一份改后端接口地址、改错误处理只改一个文件。多端项目里App 端和 H5 端可以各自实现一套同签名的 hook页面代码不用动。6.2 幂等、重试与数据一致性后端处理手机号绑定的时候要考虑并发。用户可能同时开着两个页面都点了授权两个请求带着不同的phoneCode同时到达都换出了手机号都去写同一条用户记录。如果没做处理可能出现openid对应的手机号被写两次或者唯一索引冲突报错。简单做法是用openid作为条件做更新利用数据库的原子性。如果业务要求手机号唯一加唯一索引并捕获冲突返回统一的业务错误码前端提示该手机号已绑定其他账号。重试方面getuserphonenumber返回 -1 系统繁忙时可以重试一次其他错误码不建议盲目重试尤其 40029 和 48001重试多少次都一样反而浪费调用量。重试要有上限并且带退避。6.3 上线前的逐项检查过一遍这份清单能挡掉大部分线上事故检查项确认内容主体资质手机号组件已开通免费额度与计费方式已知晓隐私指引已配置手机号信息类型并审核通过域名配置后端接口域名加入小程序的 request 合法域名基础库manifest 与后台最低版本一致不低于接口要求token 缓存使用 stable_token多实例共享缓存有并发锁日志手机号做掩码不打印明文用量监控后台用量页面有观察前端有防抖降级方案提供验证码登录等备选入口我个人在实际操作中的体会是这套流程的技术难点其实不在代码而在那些看不见的配置。前端两行代码后端两个接口真正花时间的是把资质、隐私、版本、缓存这四件事对齐。建议第一次做的时候把每一步的配置截图存档后面新项目直接照着抄能省下大半天。另外手机号验证是花钱的能力上线前一定在体验版上跑一轮完整的用户路径确认没有重复调用的逻辑漏洞这块优化比代码风格重要得多。