news 2026/9/7 21:16:08

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手机扫码登录设计全攻略:二维码状态机与安全机制详解

扫码登录这种事,看着简单,真正动手设计一遍才知道坑不少。很多团队第一次做“手机扫码登录”,第一反应就是“生成个二维码,APP扫一下,回调一下,不就好了吗?”真落地的时候就会发现,二维码内容怎么定、过期时间设多少、轮询多久一次、扫完之后为什么电脑上没自动跳转、别人拿截图能不能直接扫、两个手机同时扫会怎样……这些问题一个接一个冒出来。

这篇就把“手机扫码登录”从业务建模、二维码生命周期、前后端流程、安全设计到异常排查,完整地拆一遍。内容按详细设计文档的节奏来写,适合后端开发、客户端/前端同学,以及准备做统一登录体系的团队参考。相关热词里都在聊“微信扫码登录”,微信生态下的扫码方案我也会单独做对比,讲清楚哪些能借鉴、哪些是它特有的约束。

1. 业务模型与协议总览:先想清楚三种角色在干嘛

1.1 扫码登录的本质:二维码并不是钥匙,而是“等待领取的号码牌”

做扫码登录之前,先把业务模型理顺。扫码登录通常涉及三个参与方:

  • 设备端(电脑浏览器/客户端):负责展示二维码、主动问服务端“我这张码还活着吗、有没有人扫过了”。
  • 手机端(已登录的APP/小程序):负责扫码、识别设备端展示的码、在手机上确认“是我本人要登录”。
  • 服务端(认证中心/登录服务):状态机的持有者,维护每一个二维码从生成到过期、到被扫描、到确认或取消的完整生命周期。

这里最关键的一个设计认知是:二维码本身不承载登录身份,它只是服务端状态机里的一个临时ID,是一张“号码牌”。

我见过不少团队第一版设计,为了省事,直接把用户token加密后塞进二维码,设备端扫一下就拿token去换会话。这非常危险,w二维码会留在手机相册、聊天记录、截屏缓存里,一旦泄露等于把账号给了别人。

正确的做法是:二维码内容是一串随机且一次性的标识符,服务端只把它当作一个“凭证索引”,真正登录态在用户手机端确认后才产生。拿门禁类比:二维码是前台临时发的一张访客证,它本身不等于你公司的工牌,只有拿着访客证去前台核验身份后,才有人带你进去。

1.2 网页端扫码和微信扫码登录的差异点

这里单独说一下“微信扫码登录”为什么经常被拿来讨论。如果走的是微信开放平台网页扫码登录(就是那种电脑网页上出现一个带微信logo的二维码,用户用手机微信扫),它和自研APP扫码登录有一个本质区别:

  • 微信扫码场景中,扫码端(手机微信)是外部账号体系的身份载体,你自己的服务端不一定拥有用户在微信侧的完整登录态。
  • 自研APP扫码场景中,扫码端和登录端通常共用你自家账号体系,服务端天然知道手机端当前登录的是谁。

所以设计协议时,如果是借用微信扫码,需要在微信授权回调里先把“扫码动作”转化为“你服务端可用的用户唯一标识(openid/unionid)”,设备端才能继续走登录流程。而自研APP扫码登录,手机端确认动作可以直接调用你自家的确认登录接口,不需要中间再插一层OAuth授权。

两种方案没有优劣,取决于你的业务是否拥抱微信生态。如果只是内部系统或者自有产品矩阵做多端登录,我更推荐自研扫码登录,流程短、可控性强、不用依赖第三方网页授权回调的稳定性和审核规则。

1.3 一张图描述协议时序(用文字的方式)

很多详细设计文档一上来就画时序图,这里没法贴图,我用文字把核心时序理清楚:

  1. 设备端请求服务端“创建二维码”,携带来源信息(client_id、设备指纹、回调地址)。
  2. 服务端生成一个全局唯一qr_ticket,建立状态记录,初始状态为CREATED,返回给设备端。
  3. 设备端拿到qr_ticket后,渲染二维码(URL或编码串内容),并开始定时轮询状态接口。
  4. 手机端扫码,解析出qr_ticket,调用服务端“标记已扫”接口,携带“是哪个用户扫的”信息。
  5. 服务端更新该二维码状态为SCANNED,并在记录中暂存用户的临时凭证(不直接给device端任何token)。
  6. 手机端弹出“确认登录”页,用户点击确认,调用“确认登录”接口。
  7. 服务端校验确认,生成短期授权code/临时登录凭证,将状态改为CONFIRMED,并把凭证挂到该qr_ticket记录下。
  8. 设备轮询发现状态为CONFIRMED,用返回的code/凭证去换取正式会话token(走标准登录凭证换取流程)。
  9. 用户取消则状态变为CANCELLED,二维码失效。

这个时序里有几个细节很容易被忽略:手机端“标记已扫”和“确认登录”如果拆成两步,中间存在一个等待窗口;设备端拿到“确认成功”之后,不能用确认成功本身代表登录成功,而要用服务端下发的临时code再走一次换token流程。这样即使二维码状态在手机上被伪造,也无法绕过服务端换取会话。

1.4 二维码状态机的设计是整篇文章的地基

我强烈建议在详细设计文档里,把状态机作为单独一节写清楚。一个二维码的状态不应该只存在一次流转就结束,它至少经历这些阶段:

状态含义触发条件可流转状态
CREATED刚生成,等待被扫设备端创建二维码SCANNED、EXPIRED
SCANNED已被手机扫码,等待用户确认手机端上报扫码动作CONFIRMED、CANCELLED、EXPIRED
CONFIRMED用户已确认登录手机端确认接口调用FINISHED(交换token后不可再消费)
CANCELLED用户主动取消手机端取消接口调用终态
EXPIRED超过有效期定时任务或懒校验终态
FINISHED已换发登录态,凭证作废确认后设备端换取token终态

把状态定义清楚以后,后续的并发、重复提交、过期刷新的处理全部围绕状态机来编,不会乱。

2. 二维码生命周期与刷新策略:这些参数不要拍脑袋定

2.1 二维码内容结构怎么设计:不要太长,也不要裸奔

二维码内容一般不是“让用户扫一下就跳转”的网址,而是一个有层级的结构。我常用的形式有两种:

  • qr_ticket随机字符串:服务端通过查缓存得到上下文。
  • 结构化字符串:proto://login?ticket=xxx&app_id=xxx&scene=wx_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(host="user-redis", port=6379, db=0) 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 = f"qr_login:{ticket}" r.set(cache_key, json.dumps(record), ex=180) return ticket, record

redis key的过期时间让二维码本身也带了一个“惰性过期”的能力,不需要额外定时任务去扫过期的码。即使服务端状态机漏判,Redis也会把键清掉。轮询时如果键已经不存在,直接判为过期就行。

3.2 服务端:扫码与确认接口

手机端扫码后需要上报“谁扫了这个码”,这个动作一定要携带用户身份凭证,也就是手机端当前的登录态。服务端拿到后校验用户身份,再把状态从CREATED置为SCANNED

def scan_qr_login(ticket: str, user_identity: str): cache_key = f"qr_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), ex=120) return {"ok": True}

注意上面我把扫码后记录剩余存活时间缩短到120秒,这是有意设计的:扫码后如果用户120秒还不确认,就让它过期,避免一个扫码动作把整个3分钟窗口全占掉。这里可以让产品上倒计时重新计算,体验更清晰。

确认登录接口是真正的分水岭,因为它要产生凭证。

def confirm_qr_login(ticket: str, user_identity: str): cache_key = f"qr_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), ex=10) 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_idclient_id必须由服务端注册后生成,设备端渲染二维码前要校验当前页面URL的域名和client_id是否匹配。
  • 手机端确认登录的接口必须校验redirect_domain白名单,非法域名一律拒绝回调。
  • 二维码登录页的HTML嵌入一个一次性随机数(nonce),提交扫码上报时带回来,避免页面被篡改后直接拿着合法ticket去给另一个evil client登记。

CSRF这块通常不在扫码登录本身,但设备端登录成功后的回调跳转容易中招。跳转地址如果允许前端传参,必须要做白名单,不能让redirect_url=//evil.com被带到登录成功后的页面里去。建议所有跳转目标由服务端配置,设备端只传一个redirect_key,比如dashboardconsole

4.4 并发登录与账号互踢策略

扫码登录上线后必然要面临“同一个账号多端登录”的决策。常见策略有三种:

  • 无限制:同账号允许N台设备同时在线,每台设备各自有session。
  • 后踢前:新设备扫码登录后,旧设备全部下线(QQ早期常见)。
  • 主动管理:不自动踢,但在会话列表里展示所有已登录设备,用户可以手动踢掉异常设备。

自研后台管理系统建议默认采用“主动管理+单项踢出”,不要默认后踢前。因为扫码登录天然适合“临时想在一台公用电脑上看看内容”的场景,如果登录了新设备就把家里电脑踢下线,用户会非常烦。尤其账号是企业内部账号时,管理员能看到的会话列表也要尽可能详细:设备类型、最近活跃时间、扫码来源、登录IP。

4.5 风控维度需要采集的数据

做安全设计时,别忘了给后续风控留数据基础。设备端创建二维码时应上报:

  • 设备指纹(UA、屏幕分辨率、Canvas指纹如果允许)
  • 来源IP
  • 登录页面的referer
  • timestamp
  • 一次性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_idapp_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双层判断,接口返回错误前先读出来判断当前状态,再决定是返回“过期”还是“已取消”,别一股脑返回统一异常码,不然用户和前端都看不懂。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 21:14:30

华为MetaERP Oracle EBS 与 Oracle Fusion 在总账(GL)模块的设计上既有深厚的历史传承,又在技术架构上存在显著的代际差异。以下从设计哲学、实现逻辑、业务对象及底层技术实

Oracle EBS 与 Oracle Fusion 在总账(GL)模块的设计上既有深厚的历史传承,又在技术架构上存在显著的代际差异。以下从设计哲学、实现逻辑、业务对象及底层技术实现等维度为您进行详细剖析。一、 设计哲学与核心原理1. Oracle EBS 总账设计哲学…

作者头像 李华
网站建设 2026/9/7 21:12:11

VS 2026离线安装实战:从layout制作到报错排查

Visual Studio 做离线部署这事,我在企业内网环境里前前后后折腾过不少次。每次换新版本,总会遇到几个没见过的报错,尤其是到了 VS 2026 这一代,安装器架构延续了 2022 的 layout 模式,但组件更碎、依赖更多&#xff0c…

作者头像 李华
网站建设 2026/9/7 21:11:34

Agent智能体教程拆解:从工作流到MCP多智能体落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 21:09:18

访问修饰符详解:从Java到Python的封装与访问控制

1. 访问修饰符到底是什么:从一个“失控”的类讲起先别急着背定义。我直接说一个很多初级工程师都干过的事:写了一个User类,字段全部用public,然后业务代码里到处直接操作字段,比如user.age -1、user.passwordHash &q…

作者头像 李华