☰
SpringBoot+Vue手机验证码全链路设计与防刷实战
2026/10/23 1:31:46 网站建设 项目流程

手机验证码这个功能,说小很小,一个按钮加两个接口;说大也真不小,我亲眼见过不止一个项目在这里翻车——上线第二天就被人当成免费短信网关刷掉几千条,账单出来的时候负责人脸都绿了。这篇就把 springboot + vue 这套前后端组合下,手机验证码从方案设计、接口实现、前端交互到上线前加固的完整过程摊开讲清楚。不管你是刚接触前后端分离没多久、第一次做登录注册模块的同学,还是已经写过几遍但总觉得哪里不踏实的老手,下面的内容都能直接拿去改一改就用。我尽量不写那种“第一步创建工程、第二步引入依赖”的教科书流水账,重点放在为什么这么设计、哪些地方一定会出事、出事了怎么查这三件事上——这三件事才是真正决定这个功能能不能扛住线上流量的东西。

1. 整体方案设计与技术选型思路

1.1 验证码这件事本质上在解决什么问题

很多人一开始会把“手机验证码”理解成一个技术活:调个短信接口,存个码,比对一下,完事。真做下去会发现它其实是三个问题叠在一起:身份确权(这个手机号确实在用户手上)、资源保护(别让人白嫖你的短信费)、体验兜底(网络慢、短信延迟、用户手抖点两下,都得有应对)。

第一个问题决定了验证码必须是服务端生成、服务端存储、服务端校验,前端只负责传手机号和用户输入的数字。这一点听起来是废话,但我审过的代码里真有人把验证码用接口返回给前端、再由前端比对——这种写法等于把门锁挂在门外边,任何人打开浏览器控制台就能看到自己的验证码,甚至能看到别人的。

第二个问题决定了必须有频控。短信是按条计费的,一条几分钱看着不多,被脚本刷起来是一分钟几万条的量级。频控不能只做在前端按钮置灰上,前端的任何限制都只是“劝退”,真正的闸门必须在服务端。

第三个问题决定了要有幂等和状态管理。用户点了一次没收到,又点了一次,这时候是应该重新发还是复用?倒计时刷新页面就归零了怎么办?这些细节处理不好,用户会觉得这个产品“很糙”。

把这三层想清楚,后面的技术选型其实就没有太多悬念了。

1.2 前后端职责边界怎么切

我习惯的切法是:前端管“不让用户犯傻”,后端管“不让坏人得逞”。

前端负责的事情包括:手机号格式的即时校验(11 位、以 1 开头、第二位在合理范围)、发送按钮的倒计时与禁用态、请求过程中的 loading 防重复提交、错误提示的分级展示(格式错误就地提示,服务端返回的错误用统一 toast)。这些是体验层面的东西,做得好用户感知很明显。

后端负责的事情包括:验证码的生成与存储、发送频率控制(单手机号冷却、单手机号日限额、单 IP 限额、单设备指纹限额)、验证码的有效期、校验次数限制、校验成功后的一次性失效、短信内容模板的组装、发送失败的重试与降级、全链路日志脱敏。

有一个边界经常被搞混:手机号格式的最终校验放哪。我的做法是两边都做,但前端那次只是“提前告知”,后端那次是“最终裁决”,后端绝不能因为前端校验过了就跳过自己的校验。原因很简单,请求是可以绕过前端直接发的。

还有个小细节值得提一句:验证码的业务场景要区分开。登录、注册、找回密码、修改手机号、支付确认,这几个场景的验证码不应该混用同一个 key。我一般会在 key 里带上 scene 字段,比如sms:code:login:138xxxx和sms:code:resetpwd:138xxxx,这样即使用户在登录页拿到的码,也没法拿去走改密码流程。这个设计在安全评审的时候经常被点名表扬,成本几乎为零。

1.3 短信通道与缓存的选型考量

短信通道这块,市面上主流的几家云服务商都提供短信服务,接口形态大同小异:签名 + 模板 ID + 变量参数,返回一个请求 ID 方便对账。选型的时候我主要看四个点:到达率(这个最玄学,只能自己实测)、模板审核速度(有的要等一两个工作日)、失败码的语义清晰度(决定你能不能做精准重试)、是否支持回执查询(出问题时能不能追到具体是哪一条没到)。

这里有个经验:不要只接一家。我在一个项目里做过双通道兜底,主通道发送失败(比如返回的是通道侧的限流或维护错误码)时自动切备用通道重发一次,切换逻辑写在发送服务里,业务代码完全无感。上线后确实救过几次急。当然这要看成本,小项目单通道 + 告警也够用。

缓存这块,验证码这种“短生命周期、高频读写、允许丢失”的数据,用 Redis 是几乎唯一合理的答案。放数据库里每次校验都要走一次磁盘 IO,而且过期清理是个麻烦事;放 JVM 本地缓存(比如 Caffeine)在单机部署时能用,一旦多实例部署就会出问题——用户在 A 实例拿的码,请求被负载均衡打到 B 实例,校验必然失败。这个问题在开发环境(单机)永远复现不出来,一上生产就炸,属于典型的“环境差异坑”。

Redis 的 key 设计我在下一节展开,这里先给结论:一个验证码 key + 三个计数器 key(冷却、日限额、错误次数),全部带 TTL,不写任何清理任务。让 Redis 自己过期,比写定时任务可靠得多。

2. 核心细节解析:验证码的生成、存储与校验

2.1 验证码生成:位数、随机源与可读性

位数上,4 位太短(一万种组合,配合无限重试基本等于没有),8 位用户又记不住。6 位数字是行业里磨合出来的平衡点,一百万种组合,配合 5 次错误锁定,暴力破解的期望成本高到不划算。

随机源是很多人踩的坑。Math.random()和new Random()都是伪随机,种子可预测,理论上可以被推断出下一个值。生成验证码这种场景,标准做法是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(); }

注意SecureRandom实例要复用,不要每次调用都new。它在某些环境下初始化会阻塞(依赖系统熵池),高并发下频繁创建会拖慢响应。

还有个可读性问题:数字 0 和字母 O、1 和 l 容易混,纯数字方案天然避开了这个麻烦,所以我更推荐纯数字而不是字母数字混合。真要混合,也把容易混淆的字符从字符集里剔掉。

顺带说一句验证码内容本身不要有规律。我见过有人用手机号后 6 位、时间戳后 6 位当验证码,测试的时候图方便,后来忘了改就上线了,这种属于“自己给自己开后门”。

2.2 Redis 键设计与过期时间

我常用的键结构是这样:

键名类型值TTL作用
sms:code:{scene}:{mobile}String6 位验证码5 分钟存验证码本体
sms:cd:{scene}:{mobile}String160 秒发送冷却,防连点
sms:day:{scene}:{mobile}String计数到当天 24 点单号日限额
sms:ip:{ip}String计数1 小时单 IP 小时限额
sms:fail:{scene}:{mobile}String计数10 分钟校验错误次数

有效期选 5 分钟是个经验值。太短用户来不及输入(尤其短信有延迟),太长又给了攻击者更宽的窗口。3 到 5 分钟都合理,我一般写 5 分钟,然后在短信文案里明确写“5 分钟内有效”,用户心里有数。

日限额的 TTL 这里有个小技巧:直接算到当天 23:59:59 的秒数作为过期时间,用Duration.between(now, endOfDay).getSeconds(),这样计数天然按自然日重置,不需要额外的清理逻辑:

long seconds = Duration.between(LocalDateTime.now(), LocalDate.now().atTime(23, 59, 59)).getSeconds(); redisTemplate.opsForValue().increment(key); redisTemplate.expire(key, seconds, TimeUnit.SECONDS);

注意increment和expire这两步不是原子的,理论上中间挂掉会留下一个永不过期的 key。严谨一点的做法是把这两步写进一段 Lua 脚本里,或者干脆把日限额的 key 名里带上日期(比如sms:day:20240612:login:138xxxx),TTL 给 24 小时,靠 key 名自然滚动。后者更省事,我后来都这么干。

提示:验证码的 key 一定要带 scene,否则登录、注册、改密三个流程会互相覆盖,用户会收到“验证码错误”但其实是因为在另一个页面又发了一次。

2.3 频控规则:三层闸门缺一不可

频控我一般设三层,每层挡的不是同一类人:

第一层,单手机号冷却 60 秒。挡的是手抖连点和普通用户误操作。前端有倒计时,但后端必须再挡一次,因为接口可以被直接调用。

第二层,单手机号日限额 10 次。挡的是“盯着一个号猛刷”的脚本。10 次对正常用户完全够用,一天要收 10 条以上验证码的场景本身就说明有问题。

第三层,单 IP 小时限额 30 次。挡的是“换着手机号刷”的脚本。攻击者可以用号段生成器批量换号,绕过前两层,但 IP 换起来成本更高(尤其是需要真实可用 IP 的时候)。

三层之外,如果业务对安全要求高,还可以加设备指纹或者短信内容里的图形验证码/滑块前置。图形验证码这个设计有点意思:不是所有发送请求都要过图形码,而是触发风控阈值之后才开始要——比如同一 IP 一小时内发了 5 次之后,后续请求必须带图形验证码 token。这样正常用户永远看不到图形码,只有可疑流量才会被卡。这个策略叫“渐进式验证”,体验和安全的平衡点找得比较好。

频控的实现我倾向用 Lua 脚本一次搞定,避免多次往返和竞态:

-- KEYS[1] 冷却 key, KEYS[2] 验证码 key -- ARGV[1] 验证码值, ARGV[2] 验证码 TTL 秒, ARGV[3] 冷却 TTL 秒 if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end redis.call('SET', KEYS[1], '1', 'EX', ARGV[3]) redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2]) return 1

返回 0 表示“还在冷却期”,返回 1 表示“已写入,可以发短信”。这段脚本的好处是“检查冷却 + 写冷却 + 写验证码”三步原子完成,不会出现两个并发请求同时通过检查的情况。

2.4 校验逻辑里的三个坑

坑一:验证码没做一次性消费。校验成功后必须立刻删除 key。不删的话,同一个码在 5 分钟内可以无限次使用——如果这个码是用来登录的,等于给了一扇 5 分钟不关的门。删除操作要放在校验成功的分支里,且最好和校验放在同一个原子操作里(Lua 或者getAndDelete)。

坑二:错误次数没有上限。6 位数字一百万种组合,听起来很多,但如果不限错误次数,脚本一秒几十次请求,几分钟就能撞开。正确做法是维护sms:fail计数器,连续错 5 次就锁定 10 分钟,锁定期间连正确验证码也拒绝(这点很重要,否则攻击者可以从响应时间或错误提示里判断出“码对了但被锁了”)。

坑三:比对逻辑写成了短路比较。这个属于偏底层的细节,字符串equals在绝大多数 JVM 实现里是逐字符比对、遇错即返回,理论上可以通过响应时间差异来逐位试探。实际上在 HTTP 网络抖动面前这种时序攻击很难做,但成本几乎为零的加固没理由不做——把两边都转成字节数组用MessageDigest.isEqual比,它是常数时间的:

boolean ok = MessageDigest.isEqual( code.getBytes(StandardCharsets.UTF_8), input.getBytes(StandardCharsets.UTF_8) );

还有个细节:输入要不要 trim、要不要去掉空格。用户在手机上复制粘贴偶尔会带空格,服务端trim()一下是善意的。但不要做大小写转换,纯数字方案不存在这个问题,混合字符方案就统一转大写再比。

3. 后端实现:SpringBoot 从接口到落地

3.1 接口设计与统一响应结构

对外就两个接口:

POST /api/sms/send { mobile, scene, captchaToken? } POST /api/sms/verify { mobile, scene, code } -- 通常内嵌在登录/注册接口里

第一个返回统一结构{ code, message, data },data里带上expireIn(多少秒后失效)和retryAfter(多少秒后可以重发),前端拿这两个值来驱动倒计时,比自己写死 60 秒更准。

第二个接口我通常不单独暴露给前端,而是把校验逻辑做成一个服务方法,登录、注册接口内部调用它。原因是:如果校验和登录是两个独立接口,中间就会存在一个“验证码校验通过但还没登录”的中间态,这个状态怎么表示、怎么过期、怎么防止被重放,都是一堆麻烦事。合并成一个接口,校验通过就直接签发 token,干净利落。

统一响应结构里有个容易忽略的点:错误码要分得细。不要所有失败都返回“操作失败”,至少区分开:手机号格式错误、冷却中(带剩余秒数)、超出日限额、验证码错误(带剩余次数)、验证码已过期、验证码已被使用。前端拿到不同的码可以做不同的引导,用户看到“还需 42 秒后可重发”比看到“发送失败”体验好太多。

public enum SmsError { MOBILE_INVALID(4001, "手机号格式不正确"), TOO_FREQUENT(4002, "发送过于频繁,请稍后再试"), DAILY_LIMIT(4003, "今日发送次数已达上限"), CODE_WRONG(4004, "验证码不正确"), CODE_EXPIRED(4005, "验证码已过期,请重新获取"), CODE_LOCKED(4006, "错误次数过多,请稍后再试"); }

3.2 发送接口的完整实现

发送接口的主流程我拆成六步:参数校验 → 场景白名单校验 → 频控 → 生成验证码 → 落 Redis → 调短信通道。顺序很重要,频控一定要在“生成验证码”之前,否则被限流的请求也会白白生成一个码。

@Service @RequiredArgsConstructor public class SmsService { private final StringRedisTemplate redis; private final SmsChannel channel; private final SmsProperties props; public SmsSendResp send(String mobile, String scene) { // 1. 手机号格式 if (!MobileUtil.isValid(mobile)) { throw new BizException(SmsError.MOBILE_INVALID); } // 2. 场景白名单,防止前端传任意 scene 打乱 key 结构 if (!props.getScenes().contains(scene)) { throw new BizException(SmsError.MOBILE_INVALID); } // 3. 日限额 String dayKey = "sms:day:" + LocalDate.now() + ":" + scene + ":" + mobile; Long count = redis.opsForValue().increment(dayKey); if (count != null && count == 1L) { redis.expire(dayKey, Duration.ofDays(1)); } if (count != null && count > props.getDailyLimit()) { throw new BizException(SmsError.DAILY_LIMIT); } // 4. 冷却 + 写入(Lua 原子) String code = CodeUtil.generate(6); String cdKey = "sms:cd:" + scene + ":" + mobile; String codeKey = "sms:code:" + scene + ":" + mobile; Long ok = redis.execute(SEND_SCRIPT, Arrays.asList(cdKey, codeKey), code, String.valueOf(props.getCodeTtl()), String.valueOf(props.getCdSeconds())); if (ok == null || ok == 0L) { throw new BizException(SmsError.TOO_FREQUENT); } // 5. 发短信,失败要把 key 回滚掉,否则用户 60 秒内无法重试 try { channel.send(mobile, props.getTemplateId(scene), Map.of("code", code, "ttl", "5")); } catch (Exception e) { redis.delete(Arrays.asList(cdKey, codeKey)); throw new BizException(SmsError.SEND_FAIL); } return new SmsSendResp(props.getCodeTtl(), props.getCdSeconds()); } }

这里有个特别值得写的细节:第 5 步发送失败时,必须把冷却 key 和验证码 key 一起删掉。不删的话,用户在“发送失败”的提示下干等 60 秒才能重试,这种体验投诉我收到过不止一次。回滚逻辑写在 catch 里,用delete一次性删两个 key。

还有一点:日限额的计数不要回滚。发送失败也算一次尝试,这个设计是故意的——否则攻击者可以故意构造让通道失败的条件来绕过日限额。

3.3 校验接口与服务内嵌

校验方法返回一个布尔或者抛异常,我倾向抛异常,让调用方(登录服务)用 try-catch 统一处理:

public void verify(String mobile, String scene, String input) { if (StrUtil.isBlank(input) || input.length() != 6) { throw new BizException(SmsError.CODE_WRONG); } String failKey = "sms:fail:" + scene + ":" + mobile; String failStr = redis.opsForValue().get(failKey); int fail = failStr == null ? 0 : Integer.parseInt(failStr); if (fail >= props.getMaxFail()) { throw new BizException(SmsError.CODE_LOCKED); } String codeKey = "sms:code:" + scene + ":" + mobile; // getAndDelete 保证一次性消费,且防止并发双花 String cached = redis.opsForValue().getAndDelete(codeKey); if (cached == null) { throw new BizException(SmsError.CODE_EXPIRED); } boolean ok = MessageDigest.isEqual( cached.getBytes(StandardCharsets.UTF_8), input.getBytes(StandardCharsets.UTF_8)); if (!ok) { Long n = redis.opsForValue().increment(failKey); if (n != null && n == 1L) { redis.expire(failKey, Duration.ofMinutes(10)); } throw new BizException(SmsError.CODE_WRONG); } redis.delete(failKey); }

注意getAndDelete这一手:它把“取出”和“删除”合成一个原子操作,解决了“两个并发请求同时校验同一个码都通过”的问题。如果你的 Redis 版本比较老不支持这个命令,就用 Lua 包一下GET+DEL。

还有个细节:验证码错误时,验证码本身要不要删掉。我的选择是不删。删掉的话,用户输错一次就得重新获取,体验太差;不删配合错误次数计数,5 次之内可以继续试,超过就锁定,安全性上完全够用。

3.4 发送通道的封装与重试

短信通道这一层我用一个接口包起来,具体实现按不同的服务商写各自的适配类:

public interface SmsChannel { SendResult send(String mobile, String templateId, Map<String, String> params); }

这样做的好处是换通道不用改业务代码,测试环境还能塞一个MockSmsChannel实现,把验证码直接打到日志里(仅限本地和测试环境,且日志必须打码),省掉测试期间的短信费,也让自动化测试跑得飞快。

重试策略上,我的经验是只对明确的“通道侧临时故障”重试一次,间隔 1 秒。超时类的错误不要盲目重试,因为你不知道短信到底发出去了没有,重试可能导致用户收到两条一样的验证码——虽然用户一般不会抱怨多收一条,但对账的时候会很麻烦。

3.5 图形验证码的渐进式接入

前面提过渐进式验证。实现上就是在发送接口的最前面加一道检查:

if (riskService.needCaptcha(mobile, ip)) { if (!captchaService.verify(captchaToken)) { throw new BizException(SmsError.NEED_CAPTCHA); } }

needCaptcha的判断依据可以是“同 IP 一小时内短信发送次数 > 5”或者“同设备指纹 24 小时内发送次数 > 8”。前端收到NEED_CAPTCHA这个错误码时,弹出滑块或图形码,用户完成后再带 token 重发一次请求。

这个设计的关键在于正常用户永远触发不到。我上线这套机制之后,日均触发次数不到总发送量的 0.5%,但这 0.5% 里拦掉的异常请求占了拦截总量的绝大部分。

4. 前端实现:Vue 的倒计时、校验与体验细节

4.1 按钮状态机与倒计时的正确写法

倒计时这个小组件,新手写法通常是setInterval减一,然后在onUnmounted里clearInterval。这样写在单页面里能跑,但有几个问题:刷新页面倒计时归零(用户可以无限刷)、组件卸载时定时器泄漏(尤其在弹窗里复用时)、时间不准(setInterval本身有累积误差,还会被浏览器在后台标签页降频)。

我的做法是存截止时间戳,不存剩余秒数。每秒用当前时间减截止时间算出剩余,这样即使定时器被降频,恢复时也能立刻算对:

import { ref, onUnmounted } from 'vue' export function useCountdown() { const left = ref(0) let timer = null let deadline = 0 const tick = () => { const rest = Math.ceil((deadline - Date.now()) / 1000) left.value = rest > 0 ? rest : 0 if (rest <= 0) stop() } const start = (seconds) => { deadline = Date.now() + seconds * 1000 localStorage.setItem('sms_deadline_' + key, String(deadline)) if (timer) clearInterval(timer) tick() timer = setInterval(tick, 1000) } const stop = () => { if (timer) { clearInterval(timer); timer = null } left.value = 0 } onUnmounted(stop) return { left, start, stop } }

刷新页面倒计时归零的问题,用localStorage存截止时间解决。组件挂载时读一次,如果还没到期就接着倒计时。这个改动很小,但用户感知明显——尤其在短信延迟、用户刷新重试的场景下,没有这个逻辑用户会反复点击发送,把日限额刷爆。

onUnmounted里清理定时器这件事别偷懒,我自己在一个弹窗复用的场景里吃过亏:弹窗关掉定时器还在跑,再打开时起了第二个,倒计时直接乱跳。

4.2 请求节的防重复提交

按钮禁用只是第一道,真正管用的是请求级别的锁。用户手快、网络慢的时候,双击完全可能发出两个请求,服务端虽然有冷却兜着,但会浪费一次日限额,还可能让用户收到“发送过于频繁”的提示,莫名其妙。

const sending = ref(false) async function handleSend() { if (sending.value || left.value > 0) return if (!/^1[3-9]\d{9}$/.test(form.mobile)) { ElMessage.warning('请输入正确的手机号') return } sending.value = true try { const { data } = await api.sendSms({ mobile: form.mobile, scene: 'login' }) countdown.start(data.retryAfter || 60) ElMessage.success('验证码已发送,请注意查收') } catch (e) { // 冷却中:服务端会返回剩余秒数,直接接管倒计时 if (e.code === 4002 && e.data?.retryAfter) { countdown.start(e.data.retryAfter) } ElMessage.error(e.message || '发送失败') } finally { sending.value = false } }

有个细节值得说:服务端返回“冷却中”的时候,如果带上剩余秒数,前端应该直接把倒计时同步过去,而不是干巴巴报个错。这样即使两个人同时操作同一个账号,或者用户开了两个标签页,界面状态也能自动对齐。

4.3 输入框的几个体验细节

验证码输入框看着简单,细节不少:

  • inputmode="numeric":手机上直接弹数字键盘,别让用户在小键盘上找数字
  • maxlength="6":从物理上挡住多输
  • 自动过滤非数字:用replace(/\D/g, '')处理粘贴进来的脏数据
  • 自动提交:输满 6 位自动触发登录,省掉一次点击。这个要谨慎,如果验证码明显错了(比如服务端返回错误),要把焦点放回输入框并全选,方便重输

倒计时没结束时用户改手机号,要不要重置倒计时?我的处理是重置。因为倒计时是针对“那个手机号”的,换了号自然重新计。但这里要注意 localStorage 的 key 里也得带手机号,否则两个号的倒计时会互相干扰。

4.4 封装成可复用组件

登录、注册、改密、绑定手机这几个地方都要用,肯定是封装成组件。我的组件 API 大概是这样:

<SmsCodeField v-model="form.code" :mobile="form.mobile" scene="login" :auto-submit="true" @submit="handleLogin" />

组件内部消化掉:发送按钮、倒计时、格式校验、请求节流、错误提示、localStorage 恢复。业务页面只关心“拿到验证码之后做什么”,职责划分干净。这类组件一旦封装好,后面再加十个用到验证码的页面,每个页面的接入成本不超过五分钟。

注意:组件里不要写死接口地址和场景,通过 props 传。我见过把 scene 写死在组件里、结果注册流程用的是登录场景验证码的代码,排查了半天才发现。

5. 常见问题与排查实录

5.1 用户收不到短信,按什么顺序查

这是被问得最多的问题,我整理了一个固定顺序,从最外层往里剥:

排查顺序检查项判断依据常见原因
1业务日志里有没有发送记录搜手机号后四位请求根本没到后端
2Redis 里有没有写入验证码 keyGET sms:code:xxx频控拦截、脚本返回 0
3通道返回码是什么通道侧请求 ID 与状态码余额不足、模板未审核、签名不符
4通道侧是否显示“已提交”回执查询号码空号、停机、拦截
5用户手机是否开了拦截让用户看骚扰拦截箱被识别为营销短信

第 2 步特别值得强调:日志里不要打完整验证码,但要在 Redis 里能看到。这看起来矛盾,其实不矛盾——日志是会被到处转发和归档的,验证码写进去等于长期泄露;Redis 里的数据 5 分钟就没了,排查时用只读账号看一眼完全可控。我的做法是日志里只打手机号后四位 + scene + 通道请求 ID,需要看具体验证码时走 Redis。

第 3 步里最常见的其实是模板变量不匹配。通道侧审核通过的模板是您的验证码是${code},5分钟内有效,你代码里传的参数名写成了captcha,接口不报错,短信发出去变量位置是空的。这种问题一定要在联调阶段用真机测一次,别只看接口返回 200。

5.2 验证码明明是对的却提示错误

这个问题的排查路径和上面完全不同,基本都在校验侧:

场景一:多实例部署 + 本地缓存。前面提过,A 实例发的码存在本机 Caffeine 里,B 实例校验查不到。现象是“有时候对有时候错”,概率大概等于打到同一台机器的比例。判断方法很简单:看日志里发送和校验分别落在哪个实例。

场景二:scene 不一致。前端发送时传的 scene 是register,校验时后端登录服务里写的是login,两个 key 对不上。这种 bug 单测覆盖不到,因为单测往往两边用的都是同一个常量。我现在的做法是把 scene 定义成枚举,前后端都从这个枚举取值,从源头消灭拼错的可能。

场景三:验证码被消费了两次。有些登录流程里,校验方法被调用了两次(一次在校验拦截器里,一次在业务代码里),第一次通过并删除了 key,第二次就查不到了。现象是“第一次登录成功,第二次同样的码报已过期”——这其实是正确行为,但如果两次调用发生在同一个请求内,用户就会看到莫名其妙的失败。

场景四:Redis key 前缀不一致。配置中心里读到的前缀是prod:sms:code:,代码里硬编码的是sms:code:。多环境部署时特别容易出这个。

我把这四个场景做成了一个检查清单,出现“码对但校验不过”时依次对照,基本上三轮之内能定位。

5.3 并发和边界情况

并发发送:同一用户开了三个标签页同时点发送。后端的冷却键会拦住后两个,返回“发送过于频繁”,前端接管倒计时,界面自动对齐。这个行为是设计好的,不需要额外处理。

并发校验:用户手快点了两次登录。getAndDelete保证只有一个能拿到码,另一个会拿到 null 走“已过期”分支。但这个体验不好——用户会看到一次成功一次失败。解决办法是在前端做请求锁,或者在后端对同一用户的登录请求做短期幂等(比如同一个 token 在 3 秒内重复提交直接返回上次结果)。

验证码过期后重发:这个是最正常的流程,key 已经被 Redis 自然清掉了,重发就是全新的码,没有任何冲突。

跨天边界:用户在 23:59 发了一次,00:01 再发。因为日限额的 key 名里带了日期,跨天自动重置,符合预期。如果用的是“TTL 到当天 23:59:59”的方案,就要注意 TTL 计算出负数的情况——Duration算出负值会抛异常,得加个兜底。

5.4 上线前必须过一遍的加固清单

这几条我每次上线前都会对着检查一遍,都是踩过坑总结出来的:

  • 验证码绝不出现在任何响应体和日志里,包括异常堆栈
  • 手机号在日志里必须脱敏,中间四位打星号
  • 频控的配置项落在配置中心,不要硬编码,方便出问题时快速调整阈值
  • 短信通道的余额告警要接上,余额不足是导致“发送失败”最常见的原因之一,而且往往是悄无声息的
  • 每天至少一次对账:通道侧的发送条数和业务侧的发送记录条数比对,差异超过阈值就告警
  • 监控面板上要有发送量、成功率、验证通过率三条曲线,验证通过率突然掉到很低说明前端或后端某处改了逻辑

提示:如果业务对账发现某段时间发送量异常飙升,先看 IP 分布和前几位数字的号段分布。真实用户的号段分布是散的,脚本刷的往往集中在几个号段上。

6. 性能、安全与后续扩展

6.1 高并发下的性能考量

验证码接口本身很轻,一次 Redis 读写加一次 HTTP 调用,瓶颈几乎全部在短信通道那边。我做过一次压测,单实例(2C4G)在 Redis 同机部署的情况下,纯接口层面的 QPS 能到几千,但通道侧一般都有频控(每秒几十到几百条不等),所以真实瓶颈是通道的限流。

这意味着两件事:第一,接口层不需要做特别激进的优化,Redis 的一来一回已经不是瓶颈;第二,通道限流要做在业务侧,用令牌桶或者信号量控制发送速率,超过的直接排队或者降级,而不是等通道返回错误再处理。我一般用 Redis 做一个简单的分布式令牌桶,每秒放 N 个令牌,N 略小于通道的限额。

降级策略也得想好:通道挂了的时候,是直接报错,还是走备用通道,还是提示“短信服务暂时不可用,请稍后再试”。我的选择是双通道自动切换 + 切换失败后返回友好提示,同时在监控上立刻告警——因为短信通道故障往往不是几分钟能恢复的,得有人介入。

6.2 安全层面的几个非常规加固

除了前面说的频控、一次性消费、错误锁定,还有几个容易被忽略的点:

验证码不返回给前端这件事前面说了,但还有一种变相泄露:注册接口的响应里带上了“该手机号已注册”。攻击者可以拿这个接口批量探测手机号是否注册,虽然跟验证码没直接关系,但属于同一类信息泄露。我的做法是发送接口永远返回同样的成功提示,不管号是否存在(如果业务确实需要区分,就在校验通过之后再返回)。

接口要做来源限制:发送接口挂上登录态校验(如果业务允许)、Referer 校验、以及一个简单的签名参数(时间戳 + 随机串 + 密钥的摘要),挡掉一部分直接构造请求的脚本。这不能挡住专业的,但能把门槛抬高很多。

短信内容的模板变量要做转义,虽然验证码是纯数字不存在注入问题,但如果模板里还有用户名之类的变量,就必须处理。

限流按场景分别配置:登录场景的限额可以宽一点,改密、支付确认这类敏感场景要严格得多。一刀切的限额要么太松要么太紧。

6.3 后续可以怎么扩展

这套结构搭好之后,往上加东西其实很轻松:

换成邮箱验证码基本是零成本,把通道实现换成邮件发送,key 里的 mobile 换成 email,其他逻辑原样复用。我在一个项目里就是这么干的,两条通道共用同一套频控和校验逻辑。

加语音验证码兜底:用户多次收不到短信时,提供一个“改用语音播报”的入口。实现上多一个通道实现类,频控策略上把语音和短信合并计数。

做成通用的验证码服务:把 scene、有效期、限额、模板全部配置化,其他业务线直接接入,不用每个项目重写一遍。这个演进路径我在两个团队里都推过,收益很明显——安全策略统一了,出问题只需要改一处。

接入行为风控:把发送和校验的日志打到风控系统,用规则或模型识别异常模式(比如同一设备短时间内用多个手机号、同一手机号在不同设备上频繁操作),触发后自动提高验证强度。这个属于进阶玩法,小项目不必上,但用户量上来之后迟早要做。

7. 我个人踩过的几个坑

写到最后,分享几个只有真做过才会知道的细节,都是我自己或者身边同事真实遇到过的问题。

第一个是测试环境把验证码打到日志里,上线忘了关。灰度期间有个内部同学拿着日志里的验证码登录了别人的账号,虽然是内部人员没有造成损失,但这个口子一旦被外部利用就是灾难。后来我们的做法是把“是否输出验证码到日志”做成一个独立开关,默认关闭,只有本地 profile 下自动打开,且打包时的检查脚本会扫一遍生产配置里有没有打开这个开关。

第二个是日限额的 key 名里用了完整手机号,Redis 里的 key 数量涨得飞快。其实数量本身不是大问题(都有 TTL),问题是运维同学做 key 扫描的时候被铺满屏幕的手机号吓到了,后来改成手机号做一次摘要再拼进 key,既保证了唯一性又不暴露明文。这个改动顺带解决了“Redis 里有明文手机号”的合规隐患。

第三个是通道切换时的模板 ID 没对齐。主备两个通道的模板是各自审核的,ID 不一样,切换逻辑里却复用了同一个 ID,结果备用通道永远返回“模板不存在”。这个问题的隐蔽性在于:平时主通道好好的,切换逻辑从来没被真正触发过,等到真出故障切过去,发现备用通道也是坏的,等于白做。

第四个是倒计时和日限额的“账”对不上。用户反馈“明明只发了三次就说我超限了”,查下来是因为失败的请求也计入了日限额(前面说过这是故意的),而用户看到的“发送失败”提示让他以为是没发出去。解决办法是在提示文案里明确写“本次尝试已计入今日次数”,一开始觉得这话很别扭,但确实减少了很多客诉。

这几个坑的共同点是:问题都不在代码逻辑本身,而在“环境差异”和“状态不一致”上。所以我现在做这类功能,会额外花时间在配置管理、环境隔离和状态可观测性上,而不是一头扎进业务逻辑里。代码写对只是及格线,能查、能改、能兜底才算是做完了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询