ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uniapp微信小程序获取手机号:code换号、解密兼容与避坑

uniapp微信小程序获取手机号:code换号、解密兼容与避坑 微信小程序里做登录绕不开的一件事就是拿到用户的手机号。做 uniapp 这几年我手上跑过十几个需要手机号注册的项目从早期自己拿 encryptedData 做 AES 解密到后来官方把整个链路改成 code 换手机号中间踩的坑足够写一篇长文。这篇就把「uniapp 开发微信小程序如何获取微信用户手机号」这件事拆开讲透前端 button 怎么摆、后端两条 code 怎么串、老项目的解密代码还能不能留、以及上线后真正会让人半夜爬起来改代码的那些问题。如果你现在正处于这几种状态这篇基本能覆盖你的需求刚接手一个 uniapp 项目要做手机号一键登录手里的老项目还在跑 encryptedData 解密担心哪天失效接口能调通但偶尔报错不知道怎么排查准备上架安卓市场和小程序双端想知道哪些逻辑需要条件编译。前端我会用 Vue3 setup 的写法后端给 Node 的完整实现同时把 Java 版本的关键差异点标出来方便你直接抄。1. 需求拆解为什么获取手机号不是一行代码的事1.1 业务场景与三条技术路线的取舍先明确一件事微信小程序从头到尾都不允许你直接读取用户手机号你能拿到的只有用户主动授权后微信给你的一段凭证。这个设计决定了整个链路必然是前端拿凭证、后端换数据的两段式结构任何试图在前端直接解出手机号的做法都是错的也是不安全的。实际项目里能走的路基本三条。第一条是手机号快速验证组件也就是button加open-typegetPhoneNumber用户点一下微信弹窗确认你拿到一个 code后端换回手机号。这是目前最主流、体验最好的方式缺点是按次计费而且要求小程序是非个人主体。第二条是短信验证码用户自己输手机号你调第三方短信服务发码校验。它的好处是不受微信接口限制、不花钱相对便宜坏处是转化率明显低一截用户要切出去看短信再回来。第三条是让用户手填只做格式校验不做真实性校验这种一般只出现在内部工具或者测试环境。我在做校园跑腿、社区团购这类项目时基本都用第一种做主路径第二种兜底。原因很直接手机号一键登录的转化率比短信验证码高不少尤其是首单场景用户耐心只有几秒。但兜底路径一定要有因为总有一部分用户会点拒绝或者他的微信版本、设备环境导致授权失败这时候如果没有短信登录你就直接把用户挡在门外了。1.2 新旧接口的分水岭encryptedData 和 code 的区别这是最容易让老项目翻车的地方。早期微信给的是encryptedDataiv前端拿到这两个值连同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 classlogin-wrap !-- #ifdef MP-WEIXIN -- button classphone-btn open-typegetPhoneNumber :loadingloading :disabledloading getphonenumberonGetPhoneNumber 手机号一键登录/button !-- #endif -- !-- #ifndef MP-WEIXIN -- button classphone-btn clicktoSmsLogin短信验证码登录/button !-- #endif -- /view /template注意这里是getphonenumber而不是getPhoneNumberuni-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 带上 appidvalue 存 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后者用的是getuserphonenumberconst 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 138xxxxpurePhoneNumber是纯号码一般入库用这个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或WebClientJSON 解析用 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-typegetPhoneNumber直接写在公共模板里打包安卓时这个属性被忽略按钮点上去毫无反应查了半天代码。最后一个心得是关于错误提示的。用户点了拒绝不要弹登录失败那会让人以为程序坏了。提示语应该是未获取到手机号可以改用短信验证码登录并且顺手把短信入口露出来。这一点小小的改动在我们一个项目里把登录页的跳出率降了将近十个百分点比优化任何技术细节都管用。
返回列表