资讯动态

SpringBoot+Vue手机验证码全链路设计与防刷实战

发布时间:2026/10/23 1:31:47 来源:尧图企业网站定制
手机验证码这个功能说小很小一个按钮加两个接口说大也真不小我亲眼见过不止一个项目在这里翻车——上线第二天就被人当成免费短信网关刷掉几千条账单出来的时候负责人脸都绿了。这篇就把 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()都是伪随机种子可预测理论上可以被推断出下一个值。生成验证码这种场景标准做法是SecureRandomprivate 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:138xxxxTTL 给 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 包一下GETDEL。还有个细节验证码错误时验证码本身要不要删掉。我的选择是不删。删掉的话用户输错一次就得重新获取体验太差不删配合错误次数计数5 次之内可以继续试超过就锁定安全性上完全够用。3.4 发送通道的封装与重试短信通道这一层我用一个接口包起来具体实现按不同的服务商写各自的适配类public interface SmsChannel { SendResult send(String mobile, String templateId, MapString, 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 输入框的几个体验细节验证码输入框看着简单细节不少inputmodenumeric手机上直接弹数字键盘别让用户在小键盘上找数字maxlength6从物理上挡住多输自动过滤非数字用replace(/\D/g, )处理粘贴进来的脏数据自动提交输满 6 位自动触发登录省掉一次点击。这个要谨慎如果验证码明显错了比如服务端返回错误要把焦点放回输入框并全选方便重输倒计时没结束时用户改手机号要不要重置倒计时我的处理是重置。因为倒计时是针对“那个手机号”的换了号自然重新计。但这里要注意 localStorage 的 key 里也得带手机号否则两个号的倒计时会互相干扰。4.4 封装成可复用组件登录、注册、改密、绑定手机这几个地方都要用肯定是封装成组件。我的组件 API 大概是这样SmsCodeField v-modelform.code :mobileform.mobile scenelogin :auto-submittrue submithandleLogin /组件内部消化掉发送按钮、倒计时、格式校验、请求节流、错误提示、localStorage 恢复。业务页面只关心“拿到验证码之后做什么”职责划分干净。这类组件一旦封装好后面再加十个用到验证码的页面每个页面的接入成本不超过五分钟。注意组件里不要写死接口地址和场景通过 props 传。我见过把 scene 写死在组件里、结果注册流程用的是登录场景验证码的代码排查了半天才发现。5. 常见问题与排查实录5.1 用户收不到短信按什么顺序查这是被问得最多的问题我整理了一个固定顺序从最外层往里剥排查顺序检查项判断依据常见原因1业务日志里有没有发送记录搜手机号后四位请求根本没到后端2Redis 里有没有写入验证码 keyGET sms:code:xxx频控拦截、脚本返回 03通道返回码是什么通道侧请求 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结果备用通道永远返回“模板不存在”。这个问题的隐蔽性在于平时主通道好好的切换逻辑从来没被真正触发过等到真出故障切过去发现备用通道也是坏的等于白做。第四个是倒计时和日限额的“账”对不上。用户反馈“明明只发了三次就说我超限了”查下来是因为失败的请求也计入了日限额前面说过这是故意的而用户看到的“发送失败”提示让他以为是没发出去。解决办法是在提示文案里明确写“本次尝试已计入今日次数”一开始觉得这话很别扭但确实减少了很多客诉。这几个坑的共同点是问题都不在代码逻辑本身而在“环境差异”和“状态不一致”上。所以我现在做这类功能会额外花时间在配置管理、环境隔离和状态可观测性上而不是一头扎进业务逻辑里。代码写对只是及格线能查、能改、能兜底才算是做完了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑