
概述交友项目 App 的登录入口只有手机号用户点一下获取验证码就完成注册和登录。本文不写实现细节只把这件事拆清楚接口怎么定义、一次发码请求在服务端走了哪几步、验证码放哪里、Redis 的 key 与过期时间怎么定、为什么频控是必做项。想清楚这些后面的代码只是填空。纲要业务需求首次登录即注册的产品逻辑接口定义本篇核心交付POST /user/login获取验证码POST /user/loginVerification校验验证码并登录请求参数、响应结构、isNew标识与前端跳转的联动完整流程分析Mermaid 时序图发码 7 步 校验 5 步逐步骤给出失败时的表现关键设计决策验证码存 Redis 还是 Session 还是 MySQLRedis key 前缀与 TTL 时长6 位数字验证码的生成方式与前导零问题频控三重限制与 Redis 计数实现思路发送成功到底代表什么提交给阿里云 ≠ 用户收到数据存储设计小结Redis / MySQL 各存什么、活多久工程结构与关键类定位tree验证要点redis-cli观察命令边界与异常场景清单常见误解澄清业务需求交友项目的登录流程分成三步产品侧是这样描述的用户在登录页输入手机号点击获取验证码服务端向该手机号发送一条包含 6 位数字的短信用户把收到的验证码填回去提交服务端比对通过后进入 App 主页。本次要做的只有第 1、2 步发码以及第 3 步的服务端校验。前端页面已经由移动端团队按原型实现后台只需按约定接口提供数据。这里有个产品上的设计点要先说清楚首次登录即注册。交友项目没有独立的注册页面一个手机号第一次登录时系统自动为它建一条用户记录第二次登录直接复用。这是社交类 App 的通用做法——注册动作对用户是无感知的少一个页面、少一次转化流失。这个合一的设计会直接反映到接口上登录校验接口的返回值里必须带一个标识工程里叫isNew前端拿到true时跳完善资料页补昵称、头像、性别拿到false就直接进主页。如果不带这个字段前端就没法区分新用户和老用户产品流程就断了。接口定义两个接口都挂在/user前缀下方法体都是POST请求体都是 JSON。以下路径、参数名、返回字段均以工程代码实读为准mf-app-server模块的LoginController。获取验证码项内容请求方法POST请求路径/user/loginContent-Typeapplication/json请求参数phoneString手机号响应状态码200表示受理成功失败时用 4xx/5xx响应体null只表态不返回业务数据请求体示例{phone:13800000000}成功响应{status:200,message:OK,data:null}注意响应体里没有验证码。有些同学第一反应是把验证码返回给前端方便联调这等于把验证码白送给攻击者线上绝对不能这么干。联调阶段的替代做法是把验证码固定成常量工程在开发阶段就把code写死成123456或去 Redis 里直接看。校验验证码并登录项内容请求方法POST请求路径/user/loginVerification请求参数phoneString手机号、verificationCodeString6 位验证码响应字段tokenStringJWT、isNewBoolean是否新用户请求体示例{phone:13800000000,verificationCode:123456}成功响应{token:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibW9iaWxlIjoiMTM4MDAwMDAwMDAifQ.xxxxx,isNew:true}token是后续所有接口的通行证前端存下来放在请求头里isNew决定跳哪个页面。这两个字段少一个前端都跑不通。接口相关的工程结构mf ├── mf-app-server # 应用层对外暴露 HTTP 接口 │ └── src/main │ ├── java/com/mf/server │ │ ├── AppServerApplication.java # 引导类 │ │ ├── controller │ │ │ └── LoginController.java # /user/login、/user/loginVerification │ │ └── service │ │ └── UserService.java # 生成验证码、校验、签发 token │ └── resources │ └── application.yml # 端口 18080、Redis、短信配置 ├── mf-autoconfig # 公共服务组件自动装配 │ └── src/main/java/com/mf/autoconfig │ ├── template/SmsTemplate.java # 发送短信的模板对象 │ └── properties/SmsProperties.java # mf.sms 前缀配置 ├── mf-dubbo │ ├── mf-dubbo-interface # UserApi 接口定义 │ └── mf-dubbo-db # UserApi 的数据库实现 └── mf-model # 领域模型User 等一个请求横跨三个模块app-server收 HTTP 请求autoconfig里的SmsTemplate负责发短信dubbo-db通过UserApi读写 MySQL。分层清晰但排查问题时要注意日志会分散在多个进程里。登录页原型移动端原型登录页.html把交互约束画得很细服务端的接口设计要能扛住这些约束。┌──────────────────────────────────┐ │ │ │ [ logo ] │ │ 项目 slogn │ │ │ │ 手机号登录注册 │ │ ┌────────────────────────────┐ │ │ │ 请输入手机号 │ │ │ └────────────────────────────┘ │ │ ┌────────────────────────────┐ │ │ │ 请输入手机验证码 [获取验证码]│ │ │ └────────────────────────────┘ │ │ │ │ 已发送至 13511111111 │ │ 重新发送50s │ │ │ │ [ 登 录 ] │ └──────────────────────────────────┘要点原型标注的约束都是服务端要配合的手机号输入框失焦校验位数位数不对则输入框标红并提示请输入正确的手机号。服务端同样要校验前端校验只是体验层。验证码输入框输入错误时抖动清空、标红、提示验证码错误。这要求服务端把校验结果明确区分成功和失败不能一律返回 200。获取验证码按钮点击后进入倒计时原型标的是 50 秒倒计时结束才能重新点击——这和服务端的短周期频控是同一件事的两端。重新发送超过 3 次后需要等 1 小时才能再发。这是原型明写的产品规则服务端频控必须按这个口径落地光靠前端置灰挡不住直接调接口的脚本。页面标题是手机号登录注册而不是登录或注册——印证了首次登录即注册的产品设计。完整流程分析把发码和校验登录两段连起来看整条链路是这样的。UserApi(MySQL)RedisSmsTemplate/阿里云UserServiceLoginController项目AppUserApi(MySQL)RedisSmsTemplate/阿里云UserServiceLoginController项目App第一段获取验证码alt[命中频控][放行]第二段提交验证码登录alt[用户不存在][用户已存在]alt[验证码为空或不一致][校验通过]POST /user/login {phone}1sendMsg(phone)2校验手机号格式非空 正则3检查频控计数同号/同IP4计数超限5429 请求过于频繁6生成 6 位数字验证码7sendSms(phone, code)8返回 CodeOK已提交9SET CHECK_CODE_phone code EX 30010频控计数 1带 TTL11200 OK12POST /user/loginVerification {phone, verificationCode}13loginVerification(phone, code)14GET CHECK_CODE_phone15验证码错误16DEL CHECK_CODE_phone防重放17findByMobile(phone)18新建 User 并 saveisNewtrue19返回既有 UserisNewfalse20用 id mobile 签发 JWT21200 {token, isNew}22发码阶段每一步失败会怎样步骤动作失败时的表现①App 发起请求携带手机号网络或跨域失败前端提示重试服务端无日志②校验手机号格式非空、正则返回参数错误提示不消耗短信条数③检查发送频率频控返回请求过于频繁必须在校验通过后立刻拦截不能等发完短信再拦④生成 6 位随机验证码本地计算几乎不会失败但要保证是纯数字 6 位⑤调用短信服务发送阿里云返回非OK欠费、模板审核未过、手机号黑名单→ 记录日志并返回失败此时不能写 Redis⑥验证码写入 Redis 并设 TTLRedis 不可用 → 抛出异常否则会出现短信发出去了但没存码用户必然验证失败⑦返回成功给 App只代表服务端受理完成前端据此把按钮置灰进入倒计时第 ⑤ 步和第 ⑥ 步的顺序是个容易踩的坑先发短信、后写 Redis。如果反过来先写 Redis 再发短信一旦短信发送失败Redis 里就留了一个用户永远收不到的验证码用户重试还会被频控拦住体验极差。工程里的做法是发送成功后写缓存失败就整笔作废。校验登录阶段每一步失败会怎样步骤动作失败时的表现⑧用户提交验证码手机号或验证码为空 → 参数错误⑨从 Redis 读取比对key 不存在已过期或未发送或值不一致 → “验证码错误”不区分这两种情况避免给攻击者提示⑩比对通过后查用户表存在 → 登录不存在 → 自动注册。UserApi调用超时/MySQL 不可用 → 登录失败验证码此时不能删要让用户能重试⑪签发 JWT 返回 token签名失败或密钥缺失 → 500需要检查 JWT 配置⑫删除 Redis 中的验证码防重放删除必须在校验通过后立刻做。不删的话同一个验证码在 TTL 内可以反复使用等于没做校验第 ⑫ 步在工程实现里紧跟在校验之后、查库之前完成。如果把它挪到最后一旦后面查库或签发 token 抛异常、事务回滚验证码反而被留下来了重试又变成了重放漏洞。关键设计决策验证码存哪里Redis vs Session vs MySQL维度RedisSessionMySQL读写性能内存级微秒内存级但受会话容器限制磁盘 事务毫秒级最慢过期机制原生支持 TTL自动清理依赖会话超时粒度粗需要额外字段和定时任务清理分布式友好好多实例共享同一份数据差默认存在单机内存多实例不共享好但性能拖后腿实现复杂度低一条SET带过期即可低但扩容后会踩坑高要建表、写 SQL、做清理适用场景短生命周期、高频读写、跨节点共享单机小应用需要持久化审计结论是 Redis两个原因最硬一是分布式下 Session 不共享。交友项目的应用层日后要水平扩容成多个实例同一个用户第一次请求落在 A 实例、第二次落在 B 实例Session 里的验证码就找不到了。Redis 是外部独立存储所有实例看到的是同一份数据。二是Redis 天然支持 TTL。验证码天然是一次性、短生命周期的数据写进去的时候顺手带上过期时间到点自动消失不需要任何清理代码。用 MySQL 就得自己写定时任务扫过期数据纯属给自己找事。Redis 的 key 怎么设计工程里用CHECK_CODE_ 手机号作为 key例如手机号 13800000000 对应CHECK_CODE_13800000000加前缀不是形式主义有三个实际作用Redis 是共用的其它业务图形验证码、登录失败计数、用户信息缓存也往里写加前缀能避免 key 冲突出问题时用KEYS CHECK_CODE_*一眼能看出当前有多少人正在登录清理数据时可以按前缀批量删不会误伤别的业务。TTL 取5 分钟对应讲义和工程里的Duration.ofMinutes(5)。这个值的取舍很明确太短比如 1 分钟用户还在低头看短信码就失效了得重新发太长比如 30 分钟一个验证码在半小时内都能用来登录被撞库或爆破的风险明显上升。5 分钟是短信验证码场景里比较通用的经验值。验证码怎么生成要求是 6 位纯数字用commons-lang3的工具类一行搞定// 生成 6 位数字字符串可能包含前导零例如 012345StringcodeRandomStringUtils.randomNumeric(6);这里有个真实的细节坑前导零。randomNumeric(6)返回的是String所以012345这种以 0 开头的结果会被完整保留成 6 位。如果图省事用(int)(Math.random() * 900000) 100000去凑第一位数被人为限制成 1~9验证码空间就少了一档要是写成(int)(Math.random() * 1000000)再转字符串012345会变成12345——只有 5 位跟短信里显示的对不上用户怎么输都错。验证码从头到尾都当字符串处理不要碰整数类型。为什么要做频控这是本篇最想让读者记住的一点不做频控的短信接口等于给别人开的提款机。攻击者写个脚本循环调/user/login每调一次你的账户就扣一条短信的钱一晚上能刷掉几万块。短信是按条计费的真实成本这是真金白银的损失不是理论风险。频控要做成三重限制任何一重单独拿出来都不够用维度限制防的是什么依据同一手机号 · 短周期50~60 秒内只能发 1 次用户手抖连点、前端重复提交原型倒计时 50s同一手机号 · 中周期连续重发超过 3 次后锁 1 小时恶意刷单个号码短信轰炸原型明确标注同一 IP一小时内最多 20 次换号脚本批量刷绕开手机号维度的限制工程补充手机号维度的两条规则原型已经写死了服务端照着实现就行IP 维度原型没提但只有手机号维度的限制是挡不住脚本的——脚本换个号继续刷每个号都只发一次照样把短信费刷光所以 IP 维度必须自己补上。用 Redis 实现的核心是计数 TTL思路是每次发码前先看计数超了就拒绝没超就加一// 伪代码说明频控的判断思路完整实现见《登录发送验证码-代码实现》StringlimitKeyCHECK_CODE_LIMIT_phone;StringcountredisTemplate.opsForValue().get(limitKey);if(count!nullInteger.parseInt(count)1){// 60 秒内已经发过直接拒绝thrownewRuntimeException(请求过于频繁请稍后再试);}// 首次放行计数 1 并设置窗口期redisTemplate.opsForValue().increment(limitKey);redisTemplate.expire(limitKey,Duration.ofSeconds(60));increment是原子操作多个实例并发调用也不会把计数算错这一点比先 get 再 set靠得住。原型里超过 3 次锁 1 小时要在同一个计数的基础上再加一条规则计数达到 3 之后不再刷新短周期的 TTL而是把过期时间拉长到 1 小时。IP 维度的限制同理把 key 换成 IP 即可。频控的检查必须放在生成验证码之前先拦后发否则拦了个寂寞。发送成功代表什么SmsTemplate调用阿里云 SDK 后会拿到一个响应体Code等于OK时我们就认为发送成功。但要把这个语义讲清楚CodeOK只说明短信已经成功提交给阿里云不代表用户已经收到。从提交到用户手机上出现短信中间还要经过阿里云内部审核、运营商网关、用户手机的信号状态。运营商侧可能因为号码停机、短信通道拥堵、被拦截等原因失败这些失败阿里云不会同步告诉你只能通过回执异步回调获知。所以接口返回给前端的话术应该是验证码已发送或发送成功不要写短信已送达。前后端如果对这句话的理解不一致测试阶段就会反复扯皮测试同学说我没收到短信接口却返回成功是 bug而实际上服务端该做的都做了。数据存储设计小结存储存什么Key / 表生命周期Redis登录验证码CHECK_CODE_ 手机号5 分钟Duration.ofMinutes(5)Redis频控计数手机号维度CHECK_CODE_LIMIT_ 手机号60 秒滚动窗口Redis频控计数IP 维度前缀 IP1 小时窗口MySQL用户记录tb_user永久首次登录即注册时服务端会往tb_user里写一条新记录字段包含手机号、密码社交 App 里手机号即账号密码用md5初始化一个默认值、创建时间等。工程里的User实体只有id、mobile、password三个业务字段其余时间戳字段继承自BasePojo自动填充。要注意的是既然是自动注册冗余字段的初始化就不能漏昵称、头像、性别这些前端展示要用的字段要么在建号时给默认值要么在完善资料页补齐否则新用户进主页会看到一片空白。验证要点功能做完后按下面几条逐一确认能覆盖绝大部分问题手机能收到短信用一个真实号码调一次接口确认短信到达且内容里的验证码是 6 位数字。Redis 里能看到 key 且有 TTL登录 Redis 后执行# 查看验证码GET CHECK_CODE_13800000000# 查看剩余过期时间秒应接近 300TTL CHECK_CODE_13800000000如果TTL返回-1说明写入时忘了带过期时间验证码会永久留在 Redis 里。提交正确验证码能拿到 token用同一个验证码调/user/loginVerification返回体里应该有token和isNew。提交错误验证码被拒绝随便填一串数字应返回验证码错误。验证码用完即删登录成功后再执行GET CHECK_CODE_13800000000应该返回nil。同一手机号连续请求被频控拦截60 秒内第二次调/user/login应被拒绝而不是又发一条短信。过期后不可用等 5 分钟后再提交原验证码应提示错误。边界与异常场景清单需求分析做得够不够细看这张表补得全不全。场景期望行为备注手机号格式非法拒绝提示格式错误前置校验不消耗短信条数手机号为空拒绝phone为null时不能直接进正则短信服务调用失败返回失败不写 Redis只记日志会掩盖问题要给前端明确反馈验证码错误拒绝提示验证码错误不区分不存在和不一致避免泄露信息验证码已过期拒绝TTL 到点自动消失走的是不存在分支重复提交验证码第二次拒绝首次校验通过即删除防重放用户被冻结拒绝登录为后续用户冻结章节预留校验位置短信余额不足提示服务不可用阿里云返回余额相关错误码需要监控告警Redis 不可用快速失败返回 5xx短信已发出但无法校验宁可报错也别静默用户被冻结这一项放在这里是刻意留的钩子自动注册拿到用户后、签发 token 之前应该插入一次状态校验后面讲用户冻结时会补上这段逻辑。设计阶段就把位置占好比事后到处打补丁省事。常见误解澄清以为验证码要存数据库。短生命周期、高频读写、还要自动过期这三条命中 Redis 的强项落库纯属给自己加负担。以为发送成功就是用户收到。CodeOK只代表提交给阿里云运营商侧还可能失败文案上不能承诺已送达。以为可以不做频控。短信是按条计费的不设限就是敞开口子给人刷钱这是必修项不是选修项。以为校验完验证码可以留着。不删的话同一个码在 5 分钟内能反复用校验等于没有。校验通过立刻删是防重放的底线。以为首次登录必须单独做个注册接口。合一更省事前端也少一个页面但自动建号时别忘了初始化昵称、头像这些冗余字段。另外如果后续要统计新用户转化isNew这个标识就是唯一的数据来源丢了就补不回来。下一步需求拆完接口和流程都定了实现只是照着填空LoginController接参数、UserService生成验证码调SmsTemplate发短信写 Redis、校验接口读 Redis 比对后走UserApi查用户并签发 JWT。这些代码怎么写见下一篇《登录发送验证码-代码实现》。