ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 项目实战: 交友项目 - 登录发送验证码的需求与步骤分析

Java 项目实战: 交友项目 - 登录发送验证码的需求与步骤分析

概述

交友项目 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观察命令)
  • 边界与异常场景清单
  • 常见误解澄清

业务需求

交友项目的登录流程分成三步,产品侧是这样描述的:

  1. 用户在登录页输入手机号,点击"获取验证码";
  2. 服务端向该手机号发送一条包含 6 位数字的短信;
  3. 用户把收到的验证码填回去提交,服务端比对通过后进入 App 主页。

本次要做的只有第 1、2 步(发码)以及第 3 步的服务端校验。前端页面已经由移动端团队按原型实现,后台只需按约定接口提供数据。

这里有个产品上的设计点要先说清楚:首次登录即注册。交友项目没有独立的注册页面,一个手机号第一次登录时系统自动为它建一条用户记录,第二次登录直接复用。这是社交类 App 的通用做法——注册动作对用户是无感知的,少一个页面、少一次转化流失。

这个"合一"的设计会直接反映到接口上:登录校验接口的返回值里必须带一个标识(工程里叫isNew),前端拿到true时跳"完善资料"页(补昵称、头像、性别),拿到false就直接进主页。如果不带这个字段,前端就没法区分"新用户"和"老用户",产品流程就断了。

接口定义

两个接口都挂在/user前缀下,方法体都是POST,请求体都是 JSON。以下路径、参数名、返回字段均以工程代码实读为准(mf-app-server模块的LoginController)。

获取验证码

项内容
请求方法POST
请求路径/user/login
Content-Typeapplication/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 小时才能再发。这是原型明写的产品规则,服务端频控必须按这个口径落地,光靠前端置灰挡不住直接调接口的脚本。
  • 页面标题是"手机号登录注册",而不是"登录"或"注册"——印证了首次登录即注册的产品设计。

完整流程分析

把"发码"和"校验登录"两段连起来看,整条链路是这样的。

UserApi(MySQL)RedisSmsTemplate/阿里云UserServiceLoginController项目AppUserApi(MySQL)RedisSmsTemplate/阿里云UserServiceLoginController项目App第一段:获取验证码alt[命中频控][放行]第二段:提交验证码登录alt[用户不存在][用户已存在]alt[验证码为空或不一致][校验通过]POST /user/login {phone}1sendMsg(phone)2校验手机号格式(非空 + 正则)3检查频控计数(同号/同IP)4计数超限5429 请求过于频繁6生成 6 位数字验证码7sendSms(phone, code)8返回 Code=OK(已提交)9SET CHECK_CODE_<phone> = code EX 30010频控计数 +1(带 TTL)11200 OK12POST /user/loginVerification {phone, verificationCode}13loginVerification(phone, code)14GET CHECK_CODE_<phone>15验证码错误16DEL CHECK_CODE_<phone>(防重放)17findByMobile(phone)18新建 User 并 save,isNew=true19返回既有 User,isNew=false20用 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 位数字字符串,可能包含前导零,例如 "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 维度)前缀 + IP1 小时窗口
MySQL用户记录tb_user永久

首次登录即注册时,服务端会往tb_user里写一条新记录,字段包含手机号、密码(社交 App 里手机号即账号,密码用md5初始化一个默认值)、创建时间等。工程里的User实体只有id、mobile、password三个业务字段,其余时间戳字段继承自BasePojo自动填充。

要注意的是,既然是自动注册,冗余字段的初始化就不能漏:昵称、头像、性别这些前端展示要用的字段,要么在建号时给默认值,要么在"完善资料"页补齐,否则新用户进主页会看到一片空白。

验证要点

功能做完后,按下面几条逐一确认,能覆盖绝大部分问题:

  1. 手机能收到短信:用一个真实号码调一次接口,确认短信到达且内容里的验证码是 6 位数字。

  2. Redis 里能看到 key 且有 TTL:登录 Redis 后执行

    # 查看验证码GET CHECK_CODE_13800000000# 查看剩余过期时间(秒),应接近 300TTL CHECK_CODE_13800000000

    如果TTL返回-1,说明写入时忘了带过期时间,验证码会永久留在 Redis 里。

  3. 提交正确验证码能拿到 token:用同一个验证码调/user/loginVerification,返回体里应该有token和isNew。

  4. 提交错误验证码被拒绝:随便填一串数字,应返回"验证码错误"。

  5. 验证码用完即删:登录成功后再执行GET CHECK_CODE_13800000000,应该返回nil。

  6. 同一手机号连续请求被频控拦截:60 秒内第二次调/user/login,应被拒绝而不是又发一条短信。

  7. 过期后不可用:等 5 分钟后再提交原验证码,应提示错误。

边界与异常场景清单

需求分析做得够不够细,看这张表补得全不全。

场景期望行为备注
手机号格式非法拒绝,提示格式错误前置校验,不消耗短信条数
手机号为空拒绝phone为null时不能直接进正则
短信服务调用失败返回失败,不写 Redis只记日志会掩盖问题,要给前端明确反馈
验证码错误拒绝,提示"验证码错误"不区分"不存在"和"不一致",避免泄露信息
验证码已过期拒绝TTL 到点自动消失,走的是"不存在"分支
重复提交验证码第二次拒绝首次校验通过即删除,防重放
用户被冻结拒绝登录为后续"用户冻结"章节预留校验位置
短信余额不足提示服务不可用阿里云返回余额相关错误码,需要监控告警
Redis 不可用快速失败,返回 5xx短信已发出但无法校验,宁可报错也别静默

"用户被冻结"这一项放在这里是刻意留的钩子:自动注册拿到用户后、签发 token 之前,应该插入一次状态校验,后面讲用户冻结时会补上这段逻辑。设计阶段就把位置占好,比事后到处打补丁省事。

常见误解澄清

  • 以为验证码要存数据库。短生命周期、高频读写、还要自动过期,这三条命中 Redis 的强项,落库纯属给自己加负担。
  • 以为发送成功就是用户收到。Code=OK只代表提交给阿里云,运营商侧还可能失败,文案上不能承诺"已送达"。
  • 以为可以不做频控。短信是按条计费的,不设限就是敞开口子给人刷钱,这是必修项不是选修项。
  • 以为校验完验证码可以留着。不删的话同一个码在 5 分钟内能反复用,校验等于没有。校验通过立刻删,是防重放的底线。
  • 以为首次登录必须单独做个注册接口。合一更省事,前端也少一个页面,但自动建号时别忘了初始化昵称、头像这些冗余字段。另外,如果后续要统计"新用户转化",isNew这个标识就是唯一的数据来源,丢了就补不回来。

下一步

需求拆完,接口和流程都定了,实现只是照着填空:LoginController接参数、UserService生成验证码调SmsTemplate发短信写 Redis、校验接口读 Redis 比对后走UserApi查用户并签发 JWT。这些代码怎么写,见下一篇《登录发送验证码-代码实现》。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进