手机扫码登录设计全攻略:二维码状态机与安全机制详解

📅 发布时间:2026/9/7 21:16:27
手机扫码登录设计全攻略:二维码状态机与安全机制详解 扫码登录这种事看着简单真正动手设计一遍才知道坑不少。很多团队第一次做“手机扫码登录”第一反应就是“生成个二维码APP扫一下回调一下不就好了吗”真落地的时候就会发现二维码内容怎么定、过期时间设多少、轮询多久一次、扫完之后为什么电脑上没自动跳转、别人拿截图能不能直接扫、两个手机同时扫会怎样……这些问题一个接一个冒出来。这篇就把“手机扫码登录”从业务建模、二维码生命周期、前后端流程、安全设计到异常排查完整地拆一遍。内容按详细设计文档的节奏来写适合后端开发、客户端/前端同学以及准备做统一登录体系的团队参考。相关热词里都在聊“微信扫码登录”微信生态下的扫码方案我也会单独做对比讲清楚哪些能借鉴、哪些是它特有的约束。1. 业务模型与协议总览先想清楚三种角色在干嘛1.1 扫码登录的本质二维码并不是钥匙而是“等待领取的号码牌”做扫码登录之前先把业务模型理顺。扫码登录通常涉及三个参与方设备端电脑浏览器/客户端负责展示二维码、主动问服务端“我这张码还活着吗、有没有人扫过了”。手机端已登录的APP/小程序负责扫码、识别设备端展示的码、在手机上确认“是我本人要登录”。服务端认证中心/登录服务状态机的持有者维护每一个二维码从生成到过期、到被扫描、到确认或取消的完整生命周期。这里最关键的一个设计认知是二维码本身不承载登录身份它只是服务端状态机里的一个临时ID是一张“号码牌”。我见过不少团队第一版设计为了省事直接把用户token加密后塞进二维码设备端扫一下就拿token去换会话。这非常危险w二维码会留在手机相册、聊天记录、截屏缓存里一旦泄露等于把账号给了别人。正确的做法是二维码内容是一串随机且一次性的标识符服务端只把它当作一个“凭证索引”真正登录态在用户手机端确认后才产生。拿门禁类比二维码是前台临时发的一张访客证它本身不等于你公司的工牌只有拿着访客证去前台核验身份后才有人带你进去。1.2 网页端扫码和微信扫码登录的差异点这里单独说一下“微信扫码登录”为什么经常被拿来讨论。如果走的是微信开放平台网页扫码登录就是那种电脑网页上出现一个带微信logo的二维码用户用手机微信扫它和自研APP扫码登录有一个本质区别微信扫码场景中扫码端手机微信是外部账号体系的身份载体你自己的服务端不一定拥有用户在微信侧的完整登录态。自研APP扫码场景中扫码端和登录端通常共用你自家账号体系服务端天然知道手机端当前登录的是谁。所以设计协议时如果是借用微信扫码需要在微信授权回调里先把“扫码动作”转化为“你服务端可用的用户唯一标识openid/unionid”设备端才能继续走登录流程。而自研APP扫码登录手机端确认动作可以直接调用你自家的确认登录接口不需要中间再插一层OAuth授权。两种方案没有优劣取决于你的业务是否拥抱微信生态。如果只是内部系统或者自有产品矩阵做多端登录我更推荐自研扫码登录流程短、可控性强、不用依赖第三方网页授权回调的稳定性和审核规则。1.3 一张图描述协议时序用文字的方式很多详细设计文档一上来就画时序图这里没法贴图我用文字把核心时序理清楚设备端请求服务端“创建二维码”携带来源信息client_id、设备指纹、回调地址。服务端生成一个全局唯一qr_ticket建立状态记录初始状态为CREATED返回给设备端。设备端拿到qr_ticket后渲染二维码URL或编码串内容并开始定时轮询状态接口。手机端扫码解析出qr_ticket调用服务端“标记已扫”接口携带“是哪个用户扫的”信息。服务端更新该二维码状态为SCANNED并在记录中暂存用户的临时凭证不直接给device端任何token。手机端弹出“确认登录”页用户点击确认调用“确认登录”接口。服务端校验确认生成短期授权code/临时登录凭证将状态改为CONFIRMED并把凭证挂到该qr_ticket记录下。设备轮询发现状态为CONFIRMED用返回的code/凭证去换取正式会话token走标准登录凭证换取流程。用户取消则状态变为CANCELLED二维码失效。这个时序里有几个细节很容易被忽略手机端“标记已扫”和“确认登录”如果拆成两步中间存在一个等待窗口设备端拿到“确认成功”之后不能用确认成功本身代表登录成功而要用服务端下发的临时code再走一次换token流程。这样即使二维码状态在手机上被伪造也无法绕过服务端换取会话。1.4 二维码状态机的设计是整篇文章的地基我强烈建议在详细设计文档里把状态机作为单独一节写清楚。一个二维码的状态不应该只存在一次流转就结束它至少经历这些阶段状态含义触发条件可流转状态CREATED刚生成等待被扫设备端创建二维码SCANNED、EXPIREDSCANNED已被手机扫码等待用户确认手机端上报扫码动作CONFIRMED、CANCELLED、EXPIREDCONFIRMED用户已确认登录手机端确认接口调用FINISHED交换token后不可再消费CANCELLED用户主动取消手机端取消接口调用终态EXPIRED超过有效期定时任务或懒校验终态FINISHED已换发登录态凭证作废确认后设备端换取token终态把状态定义清楚以后后续的并发、重复提交、过期刷新的处理全部围绕状态机来编不会乱。2. 二维码生命周期与刷新策略这些参数不要拍脑袋定2.1 二维码内容结构怎么设计不要太长也不要裸奔二维码内容一般不是“让用户扫一下就跳转”的网址而是一个有层级的结构。我常用的形式有两种纯qr_ticket随机字符串服务端通过查缓存得到上下文。结构化字符串proto://login?ticketxxxapp_idxxxscenewx_qr便于扫码端解析后知道是扫码登录、是哪个应用的、去哪个接口确认。微信扫码登录场景里二维码内容通常是一个微信授权的跳转URL用户扫了以后直接进入微信授权页。而自研APP扫码场景二维码内容建议就放一个短协议串扫码APP注册对应的scheme或者用HTTP链接跳转中间页都行。结构设计上有三个建议不要放用户标识、手机号、时间戳哈希之外的任何个人敏感信息二维码会被反复截图、转发、识别。内容越短越好二维码容错能力越强。一串长度在50字符以内的消息相比200字符的消息相同像素下识别失败率低得多。尤其是用户在暗光环境、屏幕有摩尔纹、扫码距离过近时都能明显感受到差别。如果要支持多个扫码端内容里带一个scene字段扫码端根据这个判断走网页授权确认、APP拉起确认还是小程序确认。2.2 过期时间设多久短有短的好长有长的道理二维码过期时间是高频被问的问题。设计上要平衡两件事用户从打开电脑页面到掏出手机完成扫码中间通常有几十秒的心理准备期过期太短会造成大量无意义的扫码失败过期太长则让二维码截图传播后的风险窗口变大。我在生产环境里常用的是一个两段式过期模型二维码整体过期时间120秒到180秒我默认用180秒3分钟。扫码后进入“等待用户确认”的窗口额外给60秒到120秒我默认用120秒。也就是二维码生成后3分钟内没人扫就彻底失效手机扫码后用户即使在手机端停留了一会儿也有一个相对宽裕的时间确认。但如果超过整体过期时间状态仍然强制终态。为什么不定成5分钟甚至10分钟因为二维码很容易被无意识截屏留在手机里、被同事拍照、被投屏工具捕获。时间越长被别人“拿着你的码代扫”的概率越高。3分钟是一个比较舒服的窗口用户来得及风险也可控。2.3 设备端轮询频率怎么定别每秒钟打一次设备端轮询服务端是扫码登录实现里最朴素也最适用的方案。但轮询间隔不是随便填的它直接关系到服务端压力、用户体感刷新延迟、弱网下的连接开销。一般推荐做法是二维码生成后前90秒内每2秒轮询一次。因为这一步是用户“掏出手机、打开相机、对准屏幕”的阶段延迟多一点用户能接受2秒轮询能让状态变化及时回显。超过90秒进入“即将过期”阶段每3秒轮询一次稍微降低服务压力。如果后端支持可以将轮询接口加上sleep参数做成简单“长轮询”效果设备端请求时服务端没有状态变化会hold住连接5秒再返回有变化立刻返回。这个方案能减少大约60%的无效轮询请求但这种长轮询对网关超时时间有要求Nginx默认60秒都够不用太担心。有一个细节当设备端轮询到SCANNED状态时页面会显示“已扫码请在手机上确认”。很多实现这时候就停止轮询了这是错的。用户可能在手机上迟疑、搜索确认按钮在哪甚至取消后重扫一旦设备端停掉轮询用户确认后网页不会自动跳转体验直接拉垮。我见过最严谨的做法是状态机里只要不是终态CONFIRMED/FINISHED/CANCELLED/EXPIRED设备端就一直保持轮询只是可以把间隔放宽到3秒。2.4 二维码被重复使用的处理一个ticket只能活一次扫码登录设计里一个比较隐蔽的坑是ticket被重复消费。假设设备端生成的二维码被发送到一台公用电脑上、或者用户在公司大屏上登录他本人手机扫了后来另外一个人也拿手机扫同一个二维码这时候应该怎样合理策略是同一个码只能有一个用户完成“标记已扫”动作。如果SCANNED状态下另一个用户扫码明确提示“二维码已被扫描请刷新页面后重试”同时携带当前扫码用户去覆盖或拒绝不能静默允许。CONFIRMED之后任何扫码、确认、取消动作全都拒绝因为凭证已经一次性消费。设备端每次进入成功页后要立刻清理本地轮询定时器否则用户再用另一个码登录时上一个请求还在跑容易误把上个码的成功响应套到新码上。重复扫码场景我在上线统计里看到占比不小尤其是投屏环境。代码上如果偷懒不做重复扫描保护用户投诉的就不是“不能登录”而是“我莫名其妙登录成了别人的账号”这类故障级别接近安全事故了。3. 核心流程代码级实现一套可以直接抄的登录逻辑骨架3.1 服务端创建二维码接口先定义数据模型层很多团队用Redis存储扫码状态简单粗暴set qr_login:{ticket} value {json} ex 180。我建议不要只存一个状态字段把整条记录结构化存进Redis字段大致包括{ ticket: a1b2c3d4e5f6, app_id: web_console, client_id: device_fingerprint_xxxx, status: CREATED, create_time: 1713000000000, scan_time: null, scanned_by: , confirm_time: null, expire_in: 180 }创建接口逻辑就很简单了import uuid import time import json import redis r redis.Redis(hostuser-redis, port6379, db0) def create_qr_login(app_id: str, client_id: str, extra: dict None): ticket uuid.uuid4().hex record { ticket: ticket, app_id: app_id, client_id: client_id, status: CREATED, create_time: int(time.time() * 1000), scan_time: None, scanned_by: , confirm_time: None, expire_in: 180 } if extra: # 这里可以塞一个短期随机数务必不要放用户身份信息 record[extra] extra cache_key fqr_login:{ticket} r.set(cache_key, json.dumps(record), ex180) return ticket, recordredis key的过期时间让二维码本身也带了一个“惰性过期”的能力不需要额外定时任务去扫过期的码。即使服务端状态机漏判Redis也会把键清掉。轮询时如果键已经不存在直接判为过期就行。3.2 服务端扫码与确认接口手机端扫码后需要上报“谁扫了这个码”这个动作一定要携带用户身份凭证也就是手机端当前的登录态。服务端拿到后校验用户身份再把状态从CREATED置为SCANNED。def scan_qr_login(ticket: str, user_identity: str): cache_key fqr_login:{ticket} raw r.get(cache_key) if not raw: return {error: QR_CODE_EXPIRED} record json.loads(raw) if record[status] ! CREATED: # 这里统一交给调用方处理可能返回 QR_CODE_ALREADY_SCANNED return {error: QR_CODE_ALREADY_SCANNED, current_status: record[status]} record[status] SCANNED record[scan_time] int(time.time() * 1000) record[scanned_by] user_identity r.set(cache_key, json.dumps(record), ex120) return {ok: True}注意上面我把扫码后记录剩余存活时间缩短到120秒这是有意设计的扫码后如果用户120秒还不确认就让它过期避免一个扫码动作把整个3分钟窗口全占掉。这里可以让产品上倒计时重新计算体验更清晰。确认登录接口是真正的分水岭因为它要产生凭证。def confirm_qr_login(ticket: str, user_identity: str): cache_key fqr_login:{ticket} raw r.get(cache_key) if not raw: return {error: QR_CODE_EXPIRED} record json.loads(raw) if record[status] ! SCANNED: return {error: INVALID_STATUS, current_status: record[status]} if record[scanned_by] ! user_identity: return {error: NOT_SCAN_BY_YOU} record[status] CONFIRMED record[confirm_time] int(time.time() * 1000) # 一次性授权码5秒后设备端用它换真正的token grant_code uuid.uuid4().hex.upper()[:16] # 记录这个授权码要发给对应设备通过轮询token接口返还 record[grant_code] grant_code r.set(cache_key, json.dumps(record), ex10) return {grant_code: grant_code}这里有一个很重要的点确认接口返回的grant_code通常并不是直接返回给设备端做长期登录凭证的。它只是告诉设备端“用户已经点了确认你拿这个临时码去换登录token”。如果确认接口本身给的是一个长期token那一个扫码流程就等同于把长期身份凭证从手机上搬到了电脑上任何拿到这个确认响应的人都能冒充设备端。所以标准做法是确认成功后服务端生成一个短时效的grant_code我用10秒。设备端在之后的请求里用grant_code交换正式会话走标准登录接口服务端校验一次性并返回access_token refresh_token。3.3 设备端轮询状态与换token的前端开关逻辑设备端逻辑可以用很简单的状态集合来表达。前端每2到3秒请求一次轮询接口接口返回状态码。后端建议直接给语义化状态而不是数字不然前端自己翻译容易骂人。async function pollStatus(ticket) { const resp await fetch(/api/v1/qr/login/status?ticket${ticket}); const data await resp.json(); // CREATED 继续轮询UI显示请扫码 // SCANNED 继续轮询UI显示已扫码请在手机上确认 // CONFIRMED 调用 exchangeWithGrantCode(data.grant_code) // CANCELLED 停轮询UI显示您已取消登录 // EXPIRED 停轮询UI显示二维码已过期点击刷新 switch (data.status) { case CONFIRMED: clearInterval(timer); const token await exchangeGrantCode(data.grant_code, ticket); if (token) { window.location.href /dashboard?session${token}; } break; case CANCELLED: case EXPIRED: clearInterval(timer); renderExpiredHint(); break; default: // 继续等待 break; } }前端还有一个容易忽略的点二维码图片本身不要用整个qr_ticket字符串直接渲染成QR码给用户。那样二维码图片右键“在新标签页打开”直接暴露了qr_ticket。真实产品中要显示成一张图片URL二维码内容由图片接口生成且不会在页面DOM上直接展开明文。不过二维码内容本身倒在URL里暴露并不是致命伤——它的生命周期只有一个但还是要避免额外扩散。3.4 轮询接口要不要做成“状态推到设备端”虽然扫码登录最常见实现是设备端轮询但有时候会遇到一个质疑为什么不直接让手机确认成功后推送一个消息给设备端这里要区分两个场景如果设备端登录页和手机APP都在你自家生态里并且都建立了长连接WebSocket/SSE理论上可以在确认成功后通过长连接通道推消息给设备端。这个方案体验好、实时性好、还能省掉轮询请求。但绝大多数扫码登录发生在设备端是我家浏览器手机端是别人家APP的场景。浏览器页面没有常驻的长连接服务即使有WebSocket也要额外管理连接生命周期一台电脑同时开多个标签页的时候复杂度暴增。我的建议是第一版先做轮询轮询代码不超过30行。等到确认用户基数上来了、消息通道的基础设施已经存在再在保留轮询兜底的前提下把长连接推送作为单设备页面的前置加速通道。而不是为了“实时”一上来就上复杂度很高的长连接方案一旦websocket断连重连时机和扫码确认时机撞上排查问题会非常痛苦。4. 会话安全与防劫持扫码登录的坑大多出在这一层4.1 核心理念设备端拿到的所有东西都要可撤销、可轮换扫码登录本质上是把手机上的“已登录状态”复制一份到电脑设备上。系统安全设计的核心就一句话新设备不允许直接获得长生命周期的高权限凭据。我见过一版设计确认登录后设备端直接用refresh_token维持30天状态结果员工离职后管理员在后台“踢下线”只踢了手机端电脑上仍然能继续访问。这就是没有给扫码登录单独设计一套凭证体系导致的。合理模型是扫码登录成功后设备端先拿短期access_token比如24小时。同时保存refresh_token但refresh_token有独立的设备维度、可单独撤销。当用户“下线所有设备”或管理员踢某个session时根据设备指纹/会话ID把对应refresh_token吊销而不是只删手机端。服务端在刷新凭证时要能区分是“手机端登录”还是“扫码后的电脑端登录”两种渠道的设备指纹不同、风控级别也不同。扫码后的陌生设备首次登录可以触发额外的验证如邮箱验证码这点在早期设计里就要留好字段扩展位。4.2 防“扫码劫持”的页面技巧确认页别只显示账号数字说说扫码之后手机上那一步确认页的设计安全提示往往藏在这里。确认页一定要显示清晰的发起登录的设备信息和账号标识例如“Windows Chrome 浏览器正在请求登录”“登录设备浙江杭州 124.78.xx.xx”“账号zhang***example.com”“确认登录后该设备将获得访问权限”很多产品只写一句“确认登录”太单薄。攻击场景是这样的攻击者在用户电脑上已经弹出一个二维码诱导用户用手机扫一下用户扫码后确认页没有显示设备信息用户糊里糊涂点了“允许”攻击者的设备就被成功登录了。展示清晰的设备信息和账号标识至少让用户在受到诱导时有能力识别异常。截图传播这个场景也适用——如果用户把自己电脑上的二维码截图发群里同事扫了之后手机上看到的是“Windows Chrome 浏览器来自深圳”用户自己看到心里也大概有数。4.3 防CSRF、防扫码内容替换一起说扫码登录页面如果在设备端只是一个HTTP页面还面临一种攻击方式攻击者往页面里注入一个不属于你的二维码诱导用户用APP去扫。扫码后会跳转到攻击者控制的域名去完成“确认”然后拿到用户身份。应对手段二维码内容里的app_id和client_id必须由服务端注册后生成设备端渲染二维码前要校验当前页面URL的域名和client_id是否匹配。手机端确认登录的接口必须校验redirect_domain白名单非法域名一律拒绝回调。二维码登录页的HTML嵌入一个一次性随机数nonce提交扫码上报时带回来避免页面被篡改后直接拿着合法ticket去给另一个evil client登记。CSRF这块通常不在扫码登录本身但设备端登录成功后的回调跳转容易中招。跳转地址如果允许前端传参必须要做白名单不能让redirect_url//evil.com被带到登录成功后的页面里去。建议所有跳转目标由服务端配置设备端只传一个redirect_key比如dashboard、console。4.4 并发登录与账号互踢策略扫码登录上线后必然要面临“同一个账号多端登录”的决策。常见策略有三种无限制同账号允许N台设备同时在线每台设备各自有session。后踢前新设备扫码登录后旧设备全部下线QQ早期常见。主动管理不自动踢但在会话列表里展示所有已登录设备用户可以手动踢掉异常设备。自研后台管理系统建议默认采用“主动管理单项踢出”不要默认后踢前。因为扫码登录天然适合“临时想在一台公用电脑上看看内容”的场景如果登录了新设备就把家里电脑踢下线用户会非常烦。尤其账号是企业内部账号时管理员能看到的会话列表也要尽可能详细设备类型、最近活跃时间、扫码来源、登录IP。4.5 风控维度需要采集的数据做安全设计时别忘了给后续风控留数据基础。设备端创建二维码时应上报设备指纹UA、屏幕分辨率、Canvas指纹如果允许来源IP登录页面的referertimestamp一次性nonce手机端确认登录时上报手机端账号ID扫码动作发生时的地理位置如果有权限扫码端APP版本手机的设备标识这些数据不一定要立刻用于实时拦截但保存在日志里后续如果发现某段时间异常登录比例升高可以回溯到具体是哪个设备指纹、哪批来源IP的问题。我经历过一次账号被盗事件排查时就是靠扫码日志里一个异常UA特征才定位到是第三方外挂工具批量扫码所以日志字段设计一定要从一开始就带全。4.6 扫码确认的“防呆设计”重复点确认、误点取消都要兜底用户操作层面有一个高频问题扫码后手机弹窗“确认登录”用户以为是普通网页点了两次确认或者关了弹窗又重新扫了一遍。第一版设计容易踩的坑是确认接口收到重复请求时如果处理不幂等同一个grant_code会被消费两次导致第二次交换token时校验失败页面永远跳转不进去。解决思路确认登录接口本身做成幂等同一个ticket已经是CONFIRMED就直接返回当前授权码不让用户重扫。但换token的接口必须强制一次性grant_code换成功立刻删除第二次拿同一个code来换直接拒绝。服务端要区分“确认接口幂等”和“token接口不幂等”这两者语义不同容易混在一起处理。取消登录的接口也要做保护用户取消后如果再扫码不能又生成新状态让设备端稀里糊涂地继续登录。取消和确认一样属于终态决策。5. 可观测性与异常排查上线前就要规划好的东西5.1 埋点从用户看到二维码到登录成功全程要有数我每次做登录这种基础功能都会强调登录接口是全站转化漏斗的头部任何一个环节掉链子用户进不来后面功能再好都白搭。扫码登录至少要在这些节点埋点qr_create_success二维码生成成功qr_create_fail生成失败可能是后端配置错误、Redis异常qr_scanned手机端扫码成功qr_scanned_fail扫了一个已被扫/过期/不存在的码qr_confirm_success用户点击确认qr_confirm_cancel用户主动取消qr_login_success设备端换取token成功qr_login_fail换取token失败有了这些基础漏斗你可以在后台看到每一步的流失率。不夸张地讲很多扫码登录体验问题都是靠这组数据发现的。比如“qr_scanned 到 qr_confirm_success”的流失率特别高说明用户扫码后在手机上一直没找到确认按钮或者确认页加载太慢。这时候再去看手机端页面加载性能而不是盯着电脑端日志猜。5.2 服务端日志与告警别等用户投诉才知道挂了扫码登录链路涉及浏览器、电脑服务端、手机端三个环节服务端日志尤其要记全请求创建二维码时的client_id、app_id扫码上报时的ticket、user、扫码端UA确认登录时的状态流转耗时换token时的ticket、grant_code、目标IP告警规则至少要有两条创建二维码成功率低于99.5%时告警这通常意味着Redis连接或者状态存储挂了。换token成功率低于99%时告警这时候多半是凭证一次性校验逻辑被大量重复请求打破有可能有人在批量攻击。另外我还会加一条“单ticket密集轮询”告警例如同一ticket每分钟轮询超过100次大概率是设备端定时器没清理或有人在恶意拖库。轮询接口本身要做限流但对正常体量产品来说限流阈值设宽松一些不然前端在弱网环境自动重试时容易把自己用户挡住。5.3 常见问题与排查速查表把上线后我实际踩过、以及帮别人排查过的坑整成一份速查表遇到问题先对号入座现象可能原因排查手段扫码后手机端提示“二维码已过期”但页面上倒计时还没结束二维码整体过期时间到了但前端没有刷新提示看前端是否在拿到EXPIRED后结束轮询并渲染刷新按钮手机点确认后电脑页长时间不跳转设备端轮询在SCANNED后停止或轮询定时器被清理检查前端轮询代码是否在CONFIRMED之前一直运行抓接口看状态是否已CONFIRMED同一个二维码被扫多次每次提示“已被扫描”服务端没有对SCANNED状态做重复扫码校验检查scan接口的状态机校验确保只有CREATED能置SCANNED用户确认成功但token换不到grant_code已经被消费过了或者过期时间太短查Redis里key是否还在确认接口幂等时是否把同一个code返回了多次二维码内容在聊天工具里预览后无法扫描二维码串太长或编码方式导致部分字符被安全软件吞掉换成纯ticket短码用HTTP链接跳转时域名要提前加白页面刷新后旧二维码还能继续用旧session状态未在页面刷新时主动取消或过期设备端每次刷新页面时建议重新创建二维码并给旧ticket放假过期5.4 弱网环境与低端机的兼容策略扫码登录的使用环境往往没有自己想的那么美好。会议室WiFi不稳定、老手机摄像头拍照慢、电脑屏幕贴了防窥膜导致对焦差。这些物理环境问题没法从代码层根治但可以优化设备端二维码渲染出来后页面右下角放一个“点击刷新二维码”的文字按钮不要在码过期后才出现。用户扫码失败后能快速刷新。弱网下轮询接口会大量超时前端要做退避重试不要每次网络错误就直接展示失败页。重试次数可以递进失败1次后1秒重试连续失败3次后改5秒重试最多重试10次再提示网络异常。二维码图片本身要使用高对比度配色。少用浅灰、马卡龙色打印或展示扫码不是设计稿黑底白码的极简风格有时候真不如经典黑白稳妥。手机端扫码跳转确认页时如果确认页加载超过3秒没出来要给一个中间loading提示用户不会误以为没扫上而反复扫。6. 写在最后的个人体会扫码登录做了几轮以后我自己最大的体会是这个功能技术难度不高但产品细节密度极高它要求你在写第一行代码前先把状态机、安全边界、凭证生命周期、观测指标都想清楚。很多问题不是“代码写不出来”而是设计阶段漏掉了一个普通用户场景。比如“手机端扫码后用户切出去聊了个天再回来点确认”这一个普通动作就要靠扫码后的确认窗口期来兜住。另外一个容易被低估的点是扫码登录做出来后最好安排一部分精力去观测真实环境下的漏斗数据。我在踩过几次坑之后现在每次上线扫码登录第一周都会盯着“扫码成功到确认成功”和“确认成功到登录成功”这两个环节的流失确认和换token之间的时延只要超过3秒就说明服务端凭证交换链路或网络链路有瓶颈该优化就优化。最后分享一个小技巧二维码的有效期和状态缓存的过期时间不要只设一份要在创建二维码、扫码上报、确认登录三个接口各自维护一次过期逻辑。Redis虽然会自动过期key但如果你只依赖Redis TTL状态机里的“已扫码但还没确认”阶段就容易被TTL一刀切清掉。更稳妥的做法是记录级过期和Redis TTL双层判断接口返回错误前先读出来判断当前状态再决定是返回“过期”还是“已取消”别一股脑返回统一异常码不然用户和前端都看不懂。