
1. 手机验证码登录到底解决了什么问题前几年做后台系统的账号体系清一色是用户名 密码 图形验证码三件套。这套方案在桌面浏览器上跑了二十年没什么大毛病。可一旦搬到移动端问题就全冒出来了。用户在手机上打字本来就慢还得切换到密码管理器复制一串大小写数字符号混合的字符串中间再被图形验证码坑一次整套流程的流失率高得吓人。我们内部统计过一次注册到登录的转化率只有六成出头剩下的四成基本卡在忘记密码和验证码看不清这两个环节。手机验证码登录就是冲着这个场景来的。它的核心逻辑特别简单用户输入手机号后端生成一串随机数字发到手机上用户填回来后端比对一致就认为这个手机号目前在用户本人手上直接放行。整个过程只有手机号输入框、验证码输入框、发送按钮和登录按钮这四样东西前端负责倒计时、表单校验和令牌存储后端负责验证码的生成、缓存、校验以及最终身份令牌的签发。这套东西搭起来不难但要做扎实坑一点都不少——限流怎么做、验证码存哪里、令牌过期怎么续、并发重复提交怎么办每一个都能写一篇。这篇内容适合三类人一是刚学完 Spring Boot 和 Vue、想找个完整前后端分离项目练手的朋友二是正在做 SPA 项目、需要接入短信登录的开发者三是想搞清楚一次登录背后到底发生了多少次交互的前端同学。代码层面我用 JavaSpring Boot做后端、Vue 3 做前端来演示但思路是通用的换成 Node、Python、Go 一样成立。核心关键词就四个验证码、登录、代码实现、前端与后端。2. 整体架构设计与接口契约在动手写第一行代码之前我习惯先把一次完整的登录到底经过哪些环节画清楚。很多人上来就怼接口写到一半发现验证码存的位置不对、令牌刷新逻辑没地方放回头返工的成本比前期多想二十分钟高得多。这一节就把架构层面的几个关键决策定下来后面写代码基本就是填空题。2.1 一次完整登录的链路拆解把整条链路拆开其实只有两个核心接口一个是发送验证码一个是校验验证码并登录。前者做四件事——校验手机号格式、做频率限制、生成六位随机码、调用短信网关下发并把验证码写进缓存。后者做五件事——校验手机号与验证码的格式、从缓存取出验证码比对、比对失败累加错误次数、比对成功则删除验证码并签发令牌、最后返回给前端。前端这边则是三步用户点击发送验证码按钮前端先做一次本地手机号正则校验通过后调用发送接口接口返回成功就启动 60 秒倒计时并把按钮置灰用户填完六位数字点登录前端调用登录接口拿到令牌后写入本地存储后续所有需要鉴权的请求都在请求头里带上这个令牌。这里有个容易被忽略的点发送验证码接口和校验登录接口必须是完全独立的两个接口不能合并成一个提交动作。因为用户在输入手机号之后、输验证码之前中间可能退出页面、可能把 App 切到后台待五分钟这是两个时间点完全不同的动作合并接口会导致链路彻底没法做倒计时和重发。2.2 验证码该存在哪里Redis 与本地方案的取舍这是第一个真正的技术决策点。验证码有两个天然属性第一它有时效性通常 5 分钟就作废第二它是临时的登录成功后就没用了。这两个属性直接指向带过期时间的键值存储也就是 Redis 这类缓存。那能不能用 Java 的ConcurrentHashMap存在 JVM 内存里单机部署的小项目确实可以代码还简单但一旦你上多实例部署A 机器发出的验证码请求打到了 B 机器的校验接口上用户就会遇到明明收到了验证码但一直提示错误的诡异现象。而这种问题在开发环境单机永远复现不了只在生产环境多机出现排查起来极其痛苦。我见过有团队在这上面耗了整整两天。那存 MySQL 呢也不是不行但每次发送验证码要写库、每次校验要读库加上过期数据的清理任务成本高、收益低。如果项目规模确实很小、连 Redis 都不想引入可以退一步用 MySQL但一定要给验证码表加一个expire_at字段并且用定时任务定期清理。我的建议是只要用了前后端分离架构就直接上 Redis。它的SETEX或SET key value EX seconds天生就是为这种场景设计的写入即带过期时间读取不需要额外判断时间戳过期自动消失。常用的键设计大致是这样键名模式值含义过期时间用途sms:code:{手机号}六位验证码300 秒存验证码本体sms:interval:{手机号}固定值 160 秒控制重发间隔sms:daily:{手机号}当天发送次数到当天 24 点控制单日总量sms:ip:{IP}该 IP 发送次数3600 秒控制单 IP 频率sms:fail:{手机号}校验失败次数900 秒控制暴力猜码2.3 接口契约怎么定才不容易返工前后端分离项目最容易扯皮的环节就是接口字段。我的习惯是在写代码之前先把接口文档定死字段名、类型、错误码全部约定清楚前端拿着文档可以并行开发用 Mock 数据先跑通页面。登录模块的返回体我倾向于统一成下面这个结构code用业务码而不是 HTTP 状态码这样前端只需要写一次拦截器就能处理所有异常{ code: 0, message: ok, data: { token: eyJhbGciOiJIUzI1NiJ9..., expiresIn: 7200, userId: 10086, phone: 138****8000, isNewUser: false } }两个接口的定义分别是POST /api/auth/sms/send请求体{ phone: 13800008000 }POST /api/auth/sms/login请求体{ phone: 13800008000, code: 482913 }。业务码里 0 是成功1001 是手机号格式错误1002 是发送过于频繁1003 是验证码错误或已过期1004 是当日发送超限1005 是账号被临时锁定。把错误码拆细一点前端就能给出准确的提示文案而不是一律弹操作失败。3. 后端代码实现从生成验证码到签发令牌后端这块我用 Spring Boot 来演示。整体分成三层Controller 只做参数接收和返回包装Service 承载业务逻辑一个SmsClient接口负责对接实际的短信网关。这样分层的好处是将来换短信服务商只需要改一个实现类业务代码完全不用动。3.1 依赖引入与基础配置pom.xml里需要的关键依赖是这几个spring-boot-starter-web提供 Web 能力spring-boot-starter-data-redis做验证码缓存spring-boot-starter-validation做参数校验jjwt用来签发令牌。短信网关的 SDK 一般服务商都会提供但我不建议直接在业务代码里 import 它的类而是包一层自己的接口。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency配置文件中把 Redis 连接和令牌密钥写进去。密钥这一项特别提醒一句千万不要硬编码在代码里并提交到代码仓库用环境变量或者配置中心注入。我见过一个开源项目把密钥直接写在application.yml里推到公开仓库等于把所有人的令牌签发权交出去了。spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 auth: jwt: secret: ${JWT_SECRET} expire-seconds: 7200 sms: code-length: 6 code-ttl-seconds: 300 interval-seconds: 60 daily-limit: 10 ip-hourly-limit: 20 fail-limit: 5 lock-seconds: 900把这些参数抽成配置而不是写死在代码里好处是运营侧要调整策略时不用重新发版。比如大促期间想把单日上限从 10 条提到 20 条改个配置重启就行。3.2 验证码生成为什么不能用 Random生成六位数字验证码很多人顺手就写new Random().nextInt(900000) 100000。这段代码有两个问题一是Random的种子是可预测的攻击者理论上可以通过观察若干次输出来推测后续序列二是nextInt的取值范围处理不当会出现不足六位的情况比如需要补零。正确做法是用SecureRandom它是密码学安全的随机源private static final SecureRandom RANDOM new SecureRandom(); public String generateCode(int length) { StringBuilder sb new StringBuilder(length); for (int i 0; i length; i) { sb.append(RANDOM.nextInt(10)); } return sb.toString(); }注意这里逐位生成而不是整体生成一个整数这样天然保证了每一位都有值不用处理补零也不会出现首位为 0 时数字位数不够的问题。另外不要把验证码打到日志里。开发阶段为了调试方便打印一下可以理解但上线前一定要去掉日志文件往往是内部人员最容易接触到的敏感信息来源。3.3 发送验证码接口的完整实现限流我放在最前面做因为它是成本最低的一道防线——一旦短信被刷每条都是真金白银。判断顺序是先查设备锁定状态再查 60 秒间隔再查单 IP 小时限量最后查单手机号日限量任何一层不通过直接返回对应的业务码根本不进入生成和下发流程。Service RequiredArgsConstructor public class SmsAuthService { private final StringRedisTemplate redis; private final SmsClient smsClient; private final AuthProperties props; public void sendCode(String phone, String ip) { // 1. 设备是否处于锁定状态连续猜错导致 if (Boolean.TRUE.equals(redis.hasKey(sms:lock: phone))) { throw new BizException(1005, 操作过于频繁请稍后再试); } // 2. 重发间隔 Boolean first redis.opsForValue() .setIfAbsent(sms:interval: phone, 1, props.getIntervalSeconds(), TimeUnit.SECONDS); if (!Boolean.TRUE.equals(first)) { throw new BizException(1002, 发送过于频繁请稍后再试); } // 3. 单 IP 小时限量 Long ipCount redis.opsForValue().increment(sms:ip: ip); if (ipCount ! null ipCount 1L) { redis.expire(sms:ip: ip, 1, TimeUnit.HOURS); } if (ipCount ! null ipCount props.getIpHourlyLimit()) { throw new BizException(1004, 当前网络环境请求过多); } // 4. 单手机号日限量 Long daily redis.opsForValue().increment(sms:daily: phone); if (daily ! null daily 1L) { long seconds secondsUntilMidnight(); redis.expire(sms:daily: phone, seconds, TimeUnit.SECONDS); } if (daily ! null daily props.getDailyLimit()) { throw new BizException(1004, 今日发送次数已达上限); } // 5. 生成并缓存 String code generateCode(props.getCodeLength()); redis.opsForValue().set(sms:code: phone, code, props.getCodeTtlSeconds(), TimeUnit.SECONDS); // 6. 异步下发 smsClient.sendAsync(phone, code); } }这里setIfAbsent是关键它对应 Redis 的SET key value NX EX seconds是一条原子命令。如果用先get再set的写法两个并发请求可能同时通过检查间隔限制就形同虚设了。这类检查再执行的场景一定要用原子操作或者 Lua 脚本兜住。还有一个细节验证码必须在调用短信网关之前就写进缓存。有些同学怕发不出去白占内存就先发短信、成功了再写缓存。结果用户手速快短信刚到就提交了此时缓存还没写进去直接提示验证码错误。顺序反了就出这种问题。3.4 校验验证码并签发令牌校验环节的逻辑顺序同样重要先查锁定状态再取缓存中的验证码比对失败累加计数成功则清理所有相关键并签发令牌。public LoginResult login(String phone, String code) { if (Boolean.TRUE.equals(redis.hasKey(sms:lock: phone))) { throw new BizException(1005, 操作过于频繁请稍后再试); } String cached redis.opsForValue().get(sms:code: phone); if (cached null) { throw new BizException(1003, 验证码已过期请重新获取); } if (!cached.equals(code)) { Long fails redis.opsForValue().increment(sms:fail: phone); if (fails ! null fails 1L) { redis.expire(sms:fail: phone, props.getLockSeconds(), TimeUnit.SECONDS); } if (fails ! null fails props.getFailLimit()) { redis.opsForValue().set(sms:lock: phone, 1, props.getLockSeconds(), TimeUnit.SECONDS); redis.delete(sms:code: phone); throw new BizException(1005, 错误次数过多请稍后再试); } throw new BizException(1003, 验证码不正确); } // 比对成功清理痕迹 redis.delete(List.of(sms:code: phone, sms:fail: phone)); User user userService.findOrCreateByPhone(phone); String token jwtService.sign(user.getId(), phone); return new LoginResult(token, props.getJwt().getExpireSeconds(), user.getId(), mask(phone), user.isNewlyCreated()); }比对的时候有一点必须注意用equals而不是验证码是字符串比的是引用地址绝大多数情况下都会判定为不相等。这个坑看起来低级但在实际项目里真的有人踩过尤其是在 IDE 不报错的静默状态下。关于令牌的内容我只放用户 ID、一个唯一标识jti和过期时间不要在令牌里塞手机号、昵称、角色列表这些信息。令牌是 Base64 编码而非加密任何人都能解开看内容。手机号属于个人信息放进去就等于在每次请求里明文传递。要展示手机号让前端拿用户 ID 去查一次用户信息接口或者干脆在登录返回体里给一份脱敏后的字符串。3.5 短信下发的异步化处理调用短信网关是一个网络请求快则几百毫秒慢则几秒。如果同步执行用户点击按钮后要一直转圈等着体验很差。我的做法是把它丢到线程池里异步执行接口立刻返回发送成功。Async(smsExecutor) public void sendAsync(String phone, String code) { try { SmsResponse resp gateway.send(phone, templateId, Map.of(code, code)); if (!resp.isSuccess()) { log.warn(短信下发失败 phone{} respCode{}, mask(phone), resp.getCode()); } } catch (Exception e) { log.error(短信下发异常 phone{}, mask(phone), e); } }线程池要显式配置不要用默认的SimpleAsyncTaskExecutor那个是不复用线程的高并发下会不断创建新线程。核心线程数建议按每秒短信峰值 × 平均响应耗时来估算比如峰值 20 条每秒、单次调用耗时 0.3 秒理论上 6 个线程就够了留一倍余量配 12 个队列给 500拒绝策略用CallerRunsPolicy保证不丢任务。注意异步线程里不能依赖RequestContextHolder或者事务上下文那些都是绑定在请求线程上的异步线程取不到。真需要传用户信息通过方法参数显式传进去。4. 前端实现倒计时、表单校验与令牌管理前端我用 Vue 3 的组合式 API 来写配合 Element Plus 做表单。这套组合在当下的项目里出镜率非常高组件库直接用能省掉大量样式工作。4.1 登录页结构与手机号校验整个页面其实就是一个表单两个输入框加两个按钮。手机号的校验用正则国内号码用^1[3-9]\d{9}$就够用不要写那种把运营商号段写死到具体数字的正则新号段一出来就失效了。template el-form refformRef :modelform :rulesrules submit.prevent el-form-item propphone el-input v-modelform.phone maxlength11 placeholder请输入手机号 / /el-form-item el-form-item propcode el-input v-modelform.code maxlength6 placeholder请输入验证码 template #append el-button :disabledcounting || !phoneValid clickonSend {{ counting ? ${left}s 后重发 : 获取验证码 }} /el-button /template /el-input /el-form-item el-button typeprimary :loadingsubmitting clickonSubmit 登录 /el-button /el-form /template有个交互细节值得说一下发送按钮在手机号不合法时应该是禁用状态而不是让用户点了之后再弹错误提示。前者是预防后者是补救体验差一个档次。可以在手机号输入框上加input监听实时计算phoneValid。4.2 倒计时到底应该怎么写倒计时看起来简单坑却不少。最常见的错误是只用setInterval递减一个数字这会遇到三个问题一是组件卸载后定时器还在跑造成内存泄漏二是页面切到后台再切回来浏览器会节流定时器导致显示的秒数和实际经过的时间对不上三是用户刷新页面倒计时直接归零可以立刻再发一次。正确的做法是用目标时间戳来算剩余秒数而不是累减const counting ref(false); const left ref(0); let timer null; function startCountdown(seconds) { const deadline Date.now() seconds * 1000; localStorage.setItem(sms_deadline, String(deadline)); counting.value true; tick(); timer setInterval(tick, 1000); function tick() { const rest Math.ceil((deadline - Date.now()) / 1000); if (rest 0) { counting.value false; left.value 0; clearInterval(timer); timer null; localStorage.removeItem(sms_deadline); return; } left.value rest; } } onMounted(() { const saved Number(localStorage.getItem(sms_deadline) || 0); const rest Math.ceil((saved - Date.now()) / 1000); if (rest 0) startCountdown(rest); }); onUnmounted(() { if (timer) clearInterval(timer); });用时间戳的好处是无论浏览器怎么节流、页面切走多久回来算出来的剩余时间都是准的。把截止时间存到localStorage里刷新页面后倒计时也能续上避免用户靠刷新绕过限制。提示前端倒计时只是体验优化真正的限流必须在后端。前端存的那点时间戳用户改一下就是零所以后端那层SETNX才是不可绕过的。4.3 请求封装与令牌的存储位置axios的拦截器要干两件事请求前把令牌塞进请求头响应后统一处理业务错误码和 401。令牌存哪里localStorage和cookie各有取舍。localStorage用起来简单但容易受跨站脚本攻击影响cookie设成HttpOnly后前端脚本读不到安全性更好但需要后端配合处理跨域凭证。如果是内部系统或者学习项目用localStorage完全够用但记得存的是令牌本身不要顺手把手机号、身份证这类信息一起塞进去。如果是面向公众的产品建议走HttpOnlySecureSameSite的 Cookie 方案令牌只在传输层流动。const http axios.create({ baseURL: /api, timeout: 10000 }); http.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) config.headers.Authorization Bearer ${token}; return config; }); http.interceptors.response.use( (res) { const { code, message, data } res.data; if (code 0) return data; if (code 1003 || code 1005) ElMessage.warning(message); else ElMessage.error(message || 请求失败); return Promise.reject(new Error(message)); }, (err) { if (err.response?.status 401) { localStorage.removeItem(token); router.replace(/login); } return Promise.reject(err); } );这样封装之后业务代码里只需要写const data await http.post(/auth/sms/login, form)异常全部在拦截器里消化掉了页面代码干净很多。4.4 路由守卫与令牌过期处理令牌有效期到了怎么办两种策略一种是过期就让用户重新登录简单直接另一种是配一个刷新令牌访问令牌过期时自动用刷新令牌换新的用户无感知。后者的实现成本高一些但体验好。我的建议是分场景选择后台管理系统用前者反正用户每天都要登录重新登录不痛不痒面向 C 端的应用用后者用户正在下单突然被踢到登录页这个体验是非常糟糕的。路由守卫的写法很简单router.beforeEach((to) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { return { path: /login, query: { redirect: to.fullPath } }; } return true; });注意redirect参数要把用户原本想访问的地址带上登录成功后跳回去这个小细节能明显提升体验。另外守卫里不要做令牌有效性的远程校验那会让每次路由跳转都多一次网络请求正确做法是本地解码检查exp字段或者干脆不检查、等接口返回 401 再处理。5. 频率限制与防刷策略的实际配置短信接口是所有对外接口里最需要防刷的因为它直接对应成本。我见过一次事故某活动页面没做任何限流一夜之间被刷了几十万条短信账单出来的时候整个团队都傻了。这一节把限流的层次和参数梳理一下。5.1 三层限流的具体参数与理由限流不能只做一层因为单一维度的限制都有绕过方式。只限手机号攻击者可以换号只限 IP可以用大量不同来源的请求。三层叠加才有效果限流维度触发条件限制时长设计理由手机号重发间隔60 秒内第二次发送拒绝本次防手抖连点正常用户不需要这么快手机号日限量24 小时内超过 10 条拒绝本次正常用户一天登录三五次顶天了IP 小时限量1 小时内超过 20 条拒绝本次防单机批量刷正常办公网络不会超过IP 日限量24 小时内超过 100 条拒绝本次兜底防止慢速刷校验失败次数15 分钟内错 5 次锁定 15 分钟防六位码暴力枚举关于最后一条六位数字一共一百万个组合如果不限制尝试次数用脚本跑一遍也就几分钟的事。加上 5 次失败锁 15 分钟的规则后理论上需要 15 分钟才能试 5 次跑完全部组合需要几万年这个威胁就基本消除了。参数的取值没有标准答案要根据业务实际调整。比如外卖、打车这类高频场景用户可能一天登录很多次日限量就要放宽而一些低频的工具类应用5 条就够。定完之后一定要在日志里记录触发情况跑一周看看有多少正常用户被误伤再调。5.2 图形验证码该在什么时候介入纯粹的短信限流有个矛盾限得太松防不住限得太严会误伤正常用户。解法是引入自适应的人机校验——前几次发送不弹图形验证码一旦某个手机号或 IP 在短时间内发送超过阈值就在发送前弹出一个滑块或字符验证。这个判断逻辑放在后端做发送接口先检查该手机号的当日发送次数如果大于 3 次就在返回体里带一个needCaptcha: true的标记前端拿到后弹出验证组件用户通过后拿到一个一次性凭证再带着这个凭证重新请求发送接口。后端校验凭证的有效性通过才真正下发短信。这样正常用户每天发一两次完全无感而批量刷的行为会被迅速拦住——因为每次都要过一遍人机验证成本高到不值得。注意图形验证码的凭证要设计成一次性且短时效的比如 5 分钟有效、使用后立即删除。如果凭证可以重复使用攻击者过一次验证就能无限发短信整个机制就白做了。6. 常见问题排查实录这一节是我在实际项目中真真切切踩过的坑整理成速查表的形式遇到问题可以对着排查。6.1 验证码总是提示错误的三条排查路径第一种情况用户收到的和缓存里的不一致。排查方法是把收到的验证码和 Redis 里sms:code:{手机号}的值都打印出来比对。曾经遇到过一次短信模板里的变量名写错了模板用的是${1}而代码传的是code导致短信里显示的是变量名本身或者上一个变量。还有一次是模板里多加了一个空格用户复制的时候把空格也带上去了提交时报错。提交前一定要对用户输入做trim()这个动作能挡掉一大批莫名其妙的错误。第二种情况Redis 里的值已经过期了。检查TTL命令的输出看看剩余时间是不是正常。如果剩余时间明显小于配置的 300 秒说明有其他地方在重复写这个键。我遇到过一种情况是发送接口被前端调了两次一次是用户点击一次是某个组件的副作用第二次覆盖了第一次的值用户手机上看到的是第一条短信而缓存里存的是第二条。第三种情况多实例部署但没共用缓存。这个前面提过现象是有时能登上有时登不上概率大概是实例数的倒数。解决办法就是老老实实用统一的 Redis。6.2 短信收不到的几种常见原因用户反馈没收到短信实际上有相当一部分是用户自己手机的问题比如短信被手机管家拦截、收件箱满了、信号不好。这类情况我们没办法但可以在提示文案里引导用户检查拦截记录。技术侧的原因主要有几个一是网关返回了限流错误服务商那边通常也有自己的频率限制比如同一个号码同一内容 1 分钟内只能发一条这和我们的限制是叠加的要确认两边不冲突二是模板未审核通过新申请的模板需要走审核流程审核期间下发的短信会被丢弃三是余额不足这个最容易被忽略服务商的账户余额用完后接口会返回失败但很多人的代码里没有处理这个失败分支日志里只有一行warn没人注意到。我的做法是加一个监控短信接口的失败率超过 5% 就触发告警不管是网关问题还是余额问题都能第一时间发现。6.3 重复提交导致重复建号用户在提交按钮上连点两下两个请求几乎同时到达后端两个线程都查到该手机号不存在于是都执行了插入数据库里就出现了两条相同手机号的记录。这个问题在测试环境很难复现因为点击间隔通常没那么短。解决方案有三个层次我建议都做数据库层给手机号字段加唯一索引。这是最后一道防线即使代码逻辑有问题数据库也不会让脏数据落库。代码层注册逻辑用分布式锁包起来锁的键就是手机号这样同一个号码的并发请求会被串行化处理。前端层提交按钮加loading状态请求返回前直接禁用。这一层最容易做但也最容易被绕过所以不能只靠它。String lockKey lock:register: phone; Boolean locked redis.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { throw new BizException(1006, 请求处理中请稍候); } try { // 查库 插入逻辑 } finally { // 注意这里不能无脑删锁要判断是不是自己加的锁 }关于删锁严格来说应该用 Lua 脚本校验值再删或者直接用 Redisson 这类成熟框架提供的锁。自己手写删锁逻辑很容易出错比如请求 A 因为处理慢导致锁自动过期请求 B 拿到锁此时 A 处理完又把锁删了B 的锁就被误删了。7. 上线前的自查清单把上面所有内容串起来上线前对着过一遍能挡掉大部分问题。令牌密钥是否通过环境变量注入有没有出现在代码仓库里验证码是否在调用短信网关之前写入缓存日志里是否还在打印完整的验证码和手机号脱敏做了没有Redis 的过期时间是否和短信里承诺的有效期一致三层限流是否都配置了参数是否在真实数据上调过前端倒计时用的是时间戳还是累减刷新页面会不会重置校验失败次数和锁定时长的关系是否合理会不会误伤正常用户短信接口失败率是否有监控告警手机号字段是否有唯一索引令牌里是否夹带了手机号等个人信息接口返回的错误码是否足够细前端能否给出准确提示是否在真机上测试过完整流程不只是浏览器模拟器另外还有两个容易被忽略的点。一是时间同步如果服务器时间有偏差令牌的签发时间和校验时间对不上会出现刚签发的令牌就提示过期的现象部署时确保各节点都开启了时间同步服务。二是跨域配置前端开发时用代理解决上线后如果前后端不同域要正确配置允许的来源、方法和请求头尤其是Authorization头必须显式允许否则浏览器会把请求头丢掉后端拿不到令牌一律返回 401。这套方案我自己在几个项目里都用过跑起来之后基本没再出过登录相关的事故。真正花时间的从来不是写那两个接口而是限流参数怎么定、异常分支怎么处理、用户体验的细节怎么打磨。把这几块想透了代码反而是最快落地的部分。