概述
交友项目 App 的登录入口只有手机号,用户点一下"获取验证码"就完成注册和登录。本文不写实现细节,只把这件事拆清楚:接口怎么定义、一次发码请求在服务端走了哪几步、验证码放哪里、Redis 的 key 与过期时间怎么定、为什么频控是必做项。想清楚这些,后面的代码只是填空。
纲要
- 业务需求:首次登录即注册的产品逻辑
- 接口定义(本篇核心交付)
POST /user/login:获取验证码POST /user/loginVerification:校验验证码并登录- 请求参数、响应结构、
isNew标识与前端跳转的联动
- 完整流程分析(Mermaid 时序图):发码 7 步 + 校验 5 步,逐步骤给出失败时的表现
- 关键设计决策
- 验证码存 Redis 还是 Session 还是 MySQL
- Redis 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/login |
| Content-Type | application/json |
| 请求参数 | phone(String,手机号) |
| 响应状态码 | 200表示受理成功;失败时用 4xx/5xx |
| 响应体 | null(只表态,不返回业务数据) |
请求体示例:
{"phone":"13800000000"}成功响应:
{"status":200,"message":"OK","data":null}注意响应体里没有验证码。有些同学第一反应是"把验证码返回给前端方便联调",这等于把验证码白送给攻击者,线上绝对不能这么干。联调阶段的替代做法是把验证码固定成常量(工程在开发阶段就把code写死成123456),或去 Redis 里直接看。
校验验证码并登录
| 项 | 内容 |
|---|---|
| 请求方法 | POST |
| 请求路径 | /user/loginVerification |
| 请求参数 | phone(String,手机号)、verificationCode(String,6 位验证码) |
| 响应字段 | token(String,JWT)、isNew(Boolean,是否新用户) |
请求体示例:
{"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 小时才能再发。这是原型明写的产品规则,服务端频控必须按这个口径落地,光靠前端置灰挡不住直接调接口的脚本。
- 页面标题是"手机号登录注册",而不是"登录"或"注册"——印证了首次登录即注册的产品设计。
完整流程分析
把"发码"和"校验登录"两段连起来看,整条链路是这样的。
发码阶段每一步失败会怎样
| 步骤 | 动作 | 失败时的表现 |
|---|---|---|
| ① | App 发起请求,携带手机号 | 网络或跨域失败,前端提示重试,服务端无日志 |
| ② | 校验手机号格式(非空、正则) | 返回参数错误提示,不消耗短信条数 |
| ③ | 检查发送频率(频控) | 返回"请求过于频繁",必须在校验通过后立刻拦截,不能等发完短信再拦 |
| ④ | 生成 6 位随机验证码 | 本地计算,几乎不会失败,但要保证是纯数字 6 位 |
| ⑤ | 调用短信服务发送 | 阿里云返回非OK(欠费、模板审核未过、手机号黑名单)→ 记录日志并返回失败,此时不能写 Redis |
| ⑥ | 验证码写入 Redis 并设 TTL | Redis 不可用 → 抛出异常,否则会出现"短信发出去了但没存码",用户必然验证失败 |
| ⑦ | 返回成功给 App | 只代表服务端受理完成,前端据此把按钮置灰进入倒计时 |
第 ⑤ 步和第 ⑥ 步的顺序是个容易踩的坑:先发短信、后写 Redis。如果反过来先写 Redis 再发短信,一旦短信发送失败,Redis 里就留了一个用户永远收不到的验证码,用户重试还会被频控拦住,体验极差。工程里的做法是发送成功后写缓存,失败就整笔作废。
校验登录阶段每一步失败会怎样
| 步骤 | 动作 | 失败时的表现 |
|---|---|---|
| ⑧ | 用户提交验证码 | 手机号或验证码为空 → 参数错误 |
| ⑨ | 从 Redis 读取比对 | key 不存在(已过期或未发送)或值不一致 → “验证码错误”,不区分这两种情况,避免给攻击者提示 |
| ⑩ | 比对通过后查用户表 | 存在 → 登录;不存在 → 自动注册。UserApi调用超时/MySQL 不可用 → 登录失败,验证码此时不能删,要让用户能重试 |
| ⑪ | 签发 JWT 返回 token | 签名失败或密钥缺失 → 500,需要检查 JWT 配置 |
| ⑫ | 删除 Redis 中的验证码(防重放) | 删除必须在校验通过后立刻做。不删的话同一个验证码在 TTL 内可以反复使用,等于没做校验 |
第 ⑫ 步在工程实现里紧跟在校验之后、查库之前完成。如果把它挪到最后,一旦后面查库或签发 token 抛异常、事务回滚,验证码反而被留下来了,重试又变成了重放漏洞。
关键设计决策
验证码存哪里:Redis vs Session vs MySQL
| 维度 | Redis | Session | MySQL |
|---|---|---|---|
| 读写性能 | 内存级,微秒 | 内存级,但受会话容器限制 | 磁盘 + 事务,毫秒级,最慢 |
| 过期机制 | 原生支持 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 位数字字符串,可能包含前导零,例如 "012345"Stringcode=RandomStringUtils.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",思路是每次发码前先看计数,超了就拒绝,没超就加一:
// 伪代码,说明频控的判断思路,完整实现见《登录发送验证码-代码实现》StringlimitKey="CHECK_CODE_LIMIT_"+phone;Stringcount=redisTemplate.opsForValue().get(limitKey);if(count!=null&&Integer.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时我们就认为发送成功。但要把这个语义讲清楚:Code=OK只说明短信已经成功提交给阿里云,不代表用户已经收到。
从"提交"到"用户手机上出现短信",中间还要经过阿里云内部审核、运营商网关、用户手机的信号状态。运营商侧可能因为号码停机、短信通道拥堵、被拦截等原因失败,这些失败阿里云不会同步告诉你,只能通过回执(异步回调)获知。
所以接口返回给前端的话术应该是"验证码已发送"或"发送成功",不要写"短信已送达"。前后端如果对这句话的理解不一致,测试阶段就会反复扯皮:测试同学说"我没收到短信,接口却返回成功,是 bug",而实际上服务端该做的都做了。
数据存储设计小结
| 存储 | 存什么 | Key / 表 | 生命周期 |
|---|---|---|---|
| Redis | 登录验证码 | CHECK_CODE_+ 手机号 | 5 分钟(Duration.ofMinutes(5)) |
| Redis | 频控计数(手机号维度) | CHECK_CODE_LIMIT_+ 手机号 | 60 秒滚动窗口 |
| Redis | 频控计数(IP 维度) | 前缀 + IP | 1 小时窗口 |
| 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 的强项,落库纯属给自己加负担。
- 以为发送成功就是用户收到。
Code=OK只代表提交给阿里云,运营商侧还可能失败,文案上不能承诺"已送达"。 - 以为可以不做频控。短信是按条计费的,不设限就是敞开口子给人刷钱,这是必修项不是选修项。
- 以为校验完验证码可以留着。不删的话同一个码在 5 分钟内能反复用,校验等于没有。校验通过立刻删,是防重放的底线。
- 以为首次登录必须单独做个注册接口。合一更省事,前端也少一个页面,但自动建号时别忘了初始化昵称、头像这些冗余字段。另外,如果后续要统计"新用户转化",
isNew这个标识就是唯一的数据来源,丢了就补不回来。
下一步
需求拆完,接口和流程都定了,实现只是照着填空:LoginController接参数、UserService生成验证码调SmsTemplate发短信写 Redis、校验接口读 Redis 比对后走UserApi查用户并签发 JWT。这些代码怎么写,见下一篇《登录发送验证码-代码实现》。