最近把一个从零开始的项目做到收尾时,发现最让我返工的不是页面样式,也不是列表分页,反而是登录注册这一块。项目叫模拟项目X,是一款面向普通用户的内容型小程序,前面已经把基础框架、请求层和几个核心页面搭好了(这是系列第二篇,第一篇主要是工程化初始化),但当我开始接真实的注册登录时,才意识到这里面的水比想象中深得多。最初以为就是两个页面加两个接口,实际拆完业务流程之后,至少需要设计五个接口、两张数据表,还要处理token续期、并发竞态、真机调试等多个问题。这篇文章我就把整个过程、遇到的所有坑以及最终沉淀下来的方案完整写出来,给准备在自己小程序里做登录注册的朋友一个可以直接参考的样例。
1. 先理清业务:从产品需求到一份完整的接口清单
登录注册永远是业务系统的第一道门槛,但产品经理给的描述往往只有一句话:"支持手机号注册,也支持微信一键登录。"这句话背后要拆出多少东西,不做不知道,一拆吓一跳。
1.1 先画出用户从进入到使用的最短路径
我在动手前先把用户在小程序里的完整操作路径画了一遍,画出来的流程图大概是这样:
一个全新用户打开小程序时,要么直接进入登录页,要么在关键操作时被拦截到登录页。登录页上有两种选择:输入手机号和密码登录,或者点击微信一键登录。如果是新用户,走的是注册流程:填写手机号、接收验证码、设置密码、完成注册。如果使用微信一键登录,后端会根据微信的 openid 查找是否已经绑定过账号,绑定过就直接登录,没绑定过的话,需要引导用户走一遍手机号绑定流程。
这个梳理非常重要,因为它直接决定了后端接口怎么设计。如果你一上来就写两个接口,后面必然要返工。
1.2 最终确定的五个核心接口
按照上面的业务路径,我最终确定了五个核心接口,不多不少:
| 接口名称 | 请求方法 | 核心入参 | 返回关键内容 |
|---|---|---|---|
| 获取短信验证码 | POST /api/auth/sms | mobile, scene | 请求流水号、验证码有效期 |
| 手机号注册 | POST /api/auth/register | mobile, code, password | token、用户基本信息 |
| 手机号密码登录 | POST /api/auth/login | mobile, password | token、用户基本信息 |
| 微信一键登录 | POST /api/auth/wxlogin | code, userInfo | 是否已绑定、token、用户基本信息 |
| 手机号绑定 | POST /api/auth/bind | code, mobile, smsCode | token、绑定结果 |
这里尤其要注意第二和第五个接口的区别。注册是"新用户进来创建一个账号",绑定是"已经用微信身份识别出一个老用户,但该用户还没有手机号,现在补全手机号"。这两个接口如果混在一起写,后续做账号迁移、风控、找回密码全都会乱。
1.3 为什么需要单独设计"获取短信验证码"接口
很多初学者会把验证码逻辑直接放到注册接口里,让注册接口自己发短信。当时我也觉得这样省事,但真做起来发现不行。验证码在业务里是一个相对独立的资源,它要被注册、绑定、找回密码、甚至修改手机号这几个场景共用。如果把发送逻辑和注册逻辑耦合在一起,就意味着将来每个用到验证码的场景都要复制粘贴一套类似的代码。
所以我把验证码发送单独拆成接口,并且加了一个 scene 字段,标记是注册还是绑定还是重置密码。后端根据 scene 去生成对应的验证码,同一个验证码不能跨场景使用。这个设计在后来的开发和迭代中帮了大忙,至少避免了很多无谓的逻辑分支。
2. 后端数据表设计与接口实现细节
登录注册看起来是前端的事,但真正决定它稳不稳定的,是后端的表结构设计和接口边界。我用了最经典的 users 表 + user_tokens 表组合,没有引入过于复杂的用户中心中间件。
2.1 用户表:别把所有字段都塞进去
第一版设计时我想把微信的 openid、unionid、手机号、昵称、头像全部塞进一张 users 表,后来某个有经验的同事提醒我,这样一个用户如果既注册了手机号又绑定了微信,就会出现一个账号持有多个身份标识的情况,而将来很可能一个手机号要能在多台设备上登录,一个微信身份也可能换绑手机号。最好把"用户身份"和"账号密码"分开考虑。
我当时采用的是这种设计,清爽很多:
CREATE TABLE `users` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号,可空,未绑定时为空', `password_hash` VARCHAR(255) DEFAULT NULL COMMENT '密码哈希值,微信用户可空', `nickname` VARCHAR(64) DEFAULT '' COMMENT '昵称', `avatar_url` VARCHAR(512) DEFAULT '' COMMENT '头像地址', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_mobile` (`mobile`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE `user_oauths` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, `openid` VARCHAR(64) NOT NULL COMMENT '微信用户唯一标识', `unionid` VARCHAR(64) DEFAULT NULL, `source` TINYINT NOT NULL DEFAULT 1 COMMENT '1微信小程序', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_source_openid` (`source`, `openid`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;把 openid 单独放到 user_oauths 表,核心考虑是:一个用户 id 可以对应多条第三方登录记录,以后如果要接入其他小程序、App,只需要在这个表里增加 source 类型,完全不需要动主表结构。而手机号和密码是一组传统的账号密码体系,这两套体系通过 user_id 关联,逻辑非常清晰。
2.2 登录态表:不要把所有用户 session 都塞到内存里
小程序是无状态的,每次请求都得判断"这个请求是谁发来的",所以需要一个 token。但 token 必须能随时校验、能失效、能过期,我在项目里单独建了一张 user_tokens 表:
CREATE TABLE `user_tokens` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, `token_hash` CHAR(64) NOT NULL, `expires_at` DATETIME NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_token_hash` (`token_hash`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;token 存的是哈希值,而不是纯文本。如果数据库被人脱库了,攻击者拿到的也只是一堆不可逆的哈希字符串,不能直接用。这里我用的是 SHA-256 加随机盐,直接用现成的哈希函数生成。
2.3 注册接口:验证码校验、密码哈希、事务一个都不能少
注册接口的逻辑我拆成了四步:
- 校验手机号格式和验证码是否匹配、是否过期;
- 检查手机号是否已经被注册;
- 对密码做哈希;
- 插入用户记录,生成 token 并返回。
这里有两个非常关键的细节。第一个是密码一定不能明文存储,我用的是加盐的 bcrypt 算法,每次用户提交密码之后,用 bcrypt 生成哈希再存库。bcrypt 有个特性是自动带盐,同一个密码每次生成的哈希串都不一样,但验证时又能验出来,这能防止彩虹表攻击。
第二个是注册和登录态的生成必须放在同一个事务里。如果用户表插入成功但 token 生成失败,就会出现"用户已经注册成功,但客户端没拿到 token,以为注册失败"的情况。用户再点一次注册,就会提示"手机号已被占用",体验非常差。所以我用数据库事务把这两个操作包在一起,要么都成功,要么都失败。
2.4 登录接口:密码登录与微信登录的差别
密码登录的核心逻辑比较简单,拿到用户输入的手机号和密码,在 users 表里查找手机号,再用 bcrypt 比对密码哈希,一致就生成 token 返回。
微信登录要比密码登录复杂一点,核心步骤是:
- 小程序端调用 wx.login 拿到一个临时 code;
- 后端拿这个 code 去微信服务器换取 openid 和 session_key,这步在小程序后台配置好 AppID 和 AppSecret 后会自动完成,一般我们拿到的是一个 openid;
- 用 openid 去 user_oauths 表查找记录,如果找到了,直接用对应的 user_id 生成 token 返回登录成功;如果没找到,则返回一个特殊标记 isBound: false,让前端引导用户进入绑定手机号流程。
微信登录还有个需要注意的边界:同一个微信号可能曾经注册过手机号账号,后来又通过微信登录。如果不做处理,就会出现一个微信号绑定到新账号上,导致用户之前的数据全部丢失。我在绑定接口里专门写了一个逻辑:如果微信 openid 已存在但有对应的 user_id,这时用新的手机号进行绑定,就需要先确认这个手机号是否已经属于另一个账号,如果是,直接报错,让用户走账号合并或解绑流程。这个逻辑虽然不常用,但能避免很多线上客诉。
2.5 token 过期策略:我选择的是短 token 无 refresh token
有些项目会引入 refresh token 机制,token 短时间过期后,用一个长期 refresh token 去换新的 token。我在模拟项目X里没有这么做,而是把 token 有效期设置为 7 天,每次成功请求接口时滑动续期,即将 expires_at 往后延一段时间。
这个方案虽然简单,但能解决大部分场景。当然,用户长期不使用小程序,7 天之后 token 到期,前端收到 401 后自动跳转到登录页,这是可以接受的。加 refresh token 会增加状态复杂度,还要处理 refresh token 丢失、多个端同时用一个 refresh token 的竞态问题,对一个小程序项目来说性价比不高。
3. 小程序端页面与登录逻辑拆解
后端接口定了之后,前端页面其实就变成了"围绕这些接口做状态管理和用户交互"。这部分我按页面维度来拆,先解决登录状态的存放,再分别说两个页面的实现,最后说请求层怎么统一带上 token。
3.1 登录状态放在哪里:storage 是唯一靠谱的存放位置
小程序里登录状态可以放在 globalData,也可以放在 wx.setStorageSync。我第一次做的时候放在 globalData,遇到一个很麻烦的问题:用户退出小程序再点进来,globalData 会重新初始化,登录态就丢了,虽然小程序冷启动会重新执行 wx.login 恢复,但那个过程中已经有一批请求发出去了,会导致一部分请求 401。
最终我采用的是"storage 作为唯一数据源 + globalData 作为内存缓存"的方案。登录成功之后,把 token、用户信息、过期时间一并写入 storage,同时在 globalData 里缓存一份。以后每次读取,都先看 globalData,没有再读 storage,保证页面渲染不卡。
3.2 手机号密码登录页的实现细节
登录页的文件结构我尽量保持简单:wxml 放两个输入框和一个登录按钮,js 里做表单校验和请求逻辑。
核心的表单校验我放在了前端而不是后端,前端校验是为了提升用户体验,后端校验才是真正的安全边界。前端只做格式检查,例如手机号要用正则匹配 1[3-9]\d{9},密码长度不少于 6 位,空值和非法值直接弹 toast,不发起请求。
登录按钮的点击事件里,我加了一个 submitted 标志防止重复提交。用户点击按钮后、请求返回前,按钮进入 loading 状态,同时把 submitted 置为 true,请求完成后再恢复。这个细节很多人忽略,但实际体验里如果没有它,用户网络慢的时候连点三次登录按钮,会同时发出三个请求,造成重复登录或者接口报错。
3.3 微信一键登录的调用序列
微信一键登录和普通密码登录最大的区别是,它必须拿到微信的临时登录凭证 code,然后交给后端去换 openid。代码逻辑如下:
async function handleWxLogin() { const loginResult = await wx.login() if (!loginResult.code) { wx.showToast({ title: '微信登录失败', icon: 'none' }) return } const res = await request.post('/api/auth/wxlogin', { code: loginResult.code }) if (res.data.isBound) { // 已绑定,直接保存登录态并跳转 saveLoginState(res.data) wx.switchTab({ url: '/pages/index/index' }) } else { // 未绑定,跳转到绑定手机号页面 wx.navigateTo({ url: `/pages/auth/bind?code=${loginResult.code}` }) } }这里有一个很典型的坑:微信登录拿到的 code 有效期只有五分钟,而且只能用一次。如果用户第一次点击微信登录后,因为某些原因没有完成绑定,几分钟后又回到登录页点击微信登录,此时上一次的 code 可能已经失效了。所以我每次点击事件里都会重新调用 wx.login 获取新的 code,而不是把上次的 code 缓存起来复用。
另一个坑是 getPhoneNumber 按钮的授权逻辑。有些业务希望直接获取用户微信绑定的手机号,这时用的是 button 的 open-type="getPhoneNumber",通过返回的 code 或 encryptedData 去后端解密。但这项能力有一定的使用门槛和个人信息保护要求,我的模拟项目X没有强制走这个流程,而是让用户手动输入手机号,流程更透明,用户也更容易接受。
3.4 请求拦截器:每次请求自动带上 token
登录逻辑做完之后,需要在请求层做两件非常关键的事:自动附带 token,以及遇到 401 时统一处理。如果这两件不做,即便登录成功了,后续所有业务接口也都会因为缺 token 而失败。
模拟项目X使用的是自己封装的一个 request 模块,类似 axios 的小程序版。每次请求前,从 storage 里读取 token,放进 header 的 Authorization 字段;每次响应里,如果 code 是 401,则清空本地登录态,跳转到登录页,并提示登录已过期。
这个拦截器逻辑听着简单,但实现的时候有坑:如果页面同时发出三四个请求,token 过期后,这三个请求会同时收到 401,如果每个请求都触发一次"跳转登录页",用户会看到页面被 repeated 跳转。解决办法是加一个 isRedirecting 标志,第一次触发跳转后置为 true,后续的 401 只清理状态,不再重复跳转。
4. 联调阶段真实踩坑记录
接口和页面都写完之后,真正的痛苦才刚开始。联调阶段我遇到了一堆只有真机才会触发、或者在高并发场景下才会浮现的问题。我挑几个印象最深的记录在这里,每一个都花了我不少时间排查。
4.1 明明是同一套代码,开发者工具能跑,真机却请求失败
最典型的是合法域名问题。开发者在电脑上调试时,如果不勾选"不校验合法域名",请求就会收到"url not in domain list"的错误。我在真机调试时第一次遇到它,还以为是代码写错了,查了很久才发现是小程序平台强制要求所有请求域名必须在小程序后台配置白名单,并且只支持 HTTPS。
本地开发时,我用的是局域网 IP 加一个测试的 HTTP 服务,真机上完全请求不了。最后我是用本地 HTTPS 代理配合反向代理转发解决的。最简单的做法是把后端服务挂在一个已经备案的 HTTPS 域名下面,并开发一个测试环境的签名路由,然后把该域名加入合法域名列表。
如果你是自己写小程序玩,这个步骤很烦,但不要试图绕过白名单去请求任意域名,平台审核不会通过,而且这种绕过思路本身也很危险,可能会被恶意代码利用。老老实实把域名配置好,上线之后还要注意域名证书不能过期。
4.2 并发请求导致 token 刷新竞态:一次登录引发两三次重复登录
这个问题是我在真机上同时打开几个页面,短时间内触发了多个业务请求时发现的。现象是用户登录成功后,进入首页的一瞬间,首页好几个卡片接口同时发出请求,其中有一个请求因为某种原因没有带上最新的 token,后端返回 401,前端就立刻把用户"踢"回登录页。用户明明刚刚登录成功,又被要求重新登录,体验非常奇怪。
我排查后发现,主要是登录成功之后,写 storage 和并发请求之间没有严格的先后保证。解决方法是:在请求拦截器里加一个全局的 tokenReady 标志,并增加一个刷新队列。如果 token 还在刷新,其他请求就在本地排队等待,等刷新完成后再统一发出。下面这段是核心思路:
let isRefreshing = false const pendingQueue = [] function request(options) { // 省略通用参数处理 if (isRefreshing) { return new Promise((resolve, reject) => { pendingQueue.push({ options, resolve, reject }) }) } // 正常发起请求,若遇到 401 则进入刷新流程 }这个思路和 Web 端刷新 token 的思路一样,只不过在小程序里,token 刷新通常是一个重新调用 wx.login 换取新登录态的过程。加上这个队列之后,并发请求就稳定多了,不再出现那种"刚登录就下线"的诡异问题。
4.3 按钮的 loading 状态没有覆盖住整个提交流程
这个坑不是技术难点,但直接关系到用户是否会用顺手。我在注册页面加了一个"获取验证码"按钮,点击之后应该进入倒计时,但第一版代码里只在点击事件一开头做了倒计时,没有考虑网络请求失败的情况。当时网络一抖,验证码请求失败,倒计时却已经开始走了,用户等了一分钟才能重新点击,然后收到"请求失败"的提示,白白浪费 60 秒。
修复方案很明确:用 Promise 包住验证码请求,只有请求成功才启动倒计时,失败就停止 loading,并且将"重新发送"的冷却时间清零。另外,我给倒计时状态做了一个单独的管理,避免用户进页面之后连续点击多个按钮造成多个倒计时同时跑。
4.4 wx.login 成功但后端换不到 openid,怎么办
理论上说,后端拿 code 换 openid 的成功率很高,但偶尔也会出现"code 无效"的报错,尤其是在异地调试或者时间不准的情况下。排查后发现两个常见原因:一是服务器时间和小程序平台时间相差太多,导致 code 被自动判为过期;二是代码里使用了同样的 code 重复请求,第二次请求必然失败。
定位这类问题的思路不要凭感觉,而是在后端对微信接口的返回做完整日志记录,记录 code、请求时间、错误码和微信返回的原始报文。我在某个项目里就是靠这批日志发现,有一小部分请求的 code 实际上是来自开发者工具上的模拟代码,真机上不会发生。最后我在前端加了判断,如果当前环境是开发者工具,就使用 mock 登录流程,线上环境才走真实接口,这样能避免开发和测试阶段被微信侧的风控策略限制。
5. 登录注册这套代码,如何沉淀成可复用模块
搞定上面的所有问题后,我把登录注册相关的代码重新整理了一遍,抽成了一个独立的模块。原本的想法是"反正就一个项目,能用就行",但做成模块之后,第二三个小程序再接入时,省下来的时间非常可观。
5.1 目录结构:把页面、请求、工具区分开
我在 miniprogram/pages/auth 下放了登录、注册、绑定三个页面,然后在 miniprogram/utils/auth.js 里封装了所有登录状态管理逻辑,包括登录、登出、token 读写、过期判断。这样页面层只负责 UI 操作,具体状态管理全部收敛到 auth.js 一个文件里。
这样做最大的好处是,换到另一个小程序项目时,直接把 auth 目录复制过去,改一下配置项里的 AppID 和后端地址,再把页面样式做适配,登录注册就全部可用了。我最近做的一个小程序,从开始接入到登录流程跑通,只花了一个下午的时间,这在以前是完全不敢想的。
5.2 可配置项:登录方式、token 有效期、是否启用微信登录
我把登录模块里所有可能变化的参数都放在了 config 文件里,比如:
module.exports = { loginType: ['mobile', 'wechat'], // 可只保留手机号登录 tokenExpireDays: 7, autoLogin: true, // 是否在启动时静默登录 redirectStrategy: 'redirect' // 登录成功后跳转方式 }通过配置项来控制模块行为,而不是每做一个新项目就去改业务代码。这样可以保证核心逻辑经过多个项目的验证,不会因为复制粘贴而越改越乱。
5.3 后续可以扩展的方向
现在的模块已经能覆盖绝大多数小程序登录注册需求。如果要往更复杂的方向演进,我觉得可以考虑几个扩展点:支持多端登录互踢、用户账号注销、设备管理、增强风控(比如异常 IP 登录提醒)。这些内容在小程序初期不一定需要,但接口设计时提前预留 user_oauths 表和 token 过期字段,后面扩展会容易得多。
我在做模拟项目X时有一个很深的体会:登录注册这种模块,平时感觉不到它的存在,一旦出问题,所有用户都会在第一步就被卡住。它不产生任何直接业务价值,但它的稳定性直接决定了整个小程序的使用体验。把这一块做扎实、做成模块、做成经过多项目验证的资产,是非常值得投入的一件事。
最后分享两个我在实际开发中一直坚持的习惯:第一,所有涉及验证码和密码的接口,无论前端怎么校验,后端必须再完整校验一遍,前端校验只是给用户看的提示,后端校验才是安全底线;第二,登录注册相关的异常日志要单独归档,别看它们不起眼,一旦线上出现用户反馈登录不上,这些日志就是排查的第一手线索。