概述
接口写完了不等于流程跑通了。这一篇不重复接口怎么设计、代码怎么写,只解决一件事:怎么用一套可复现的步骤证明"发验证码 + 登录"这条链路真的通了,以及出问题时该看哪里。
纲要
- 验证清单
- 六项检查点:接口连通性 / 短信是否真发出 / 验证码是否落 Redis / 比对逻辑 / 首次登录即注册 / 频控
- 每项给"怎么验、预期结果、不通过说明什么"
- 端到端验证步骤
- 单独验证短信通道(
SmsTemplateTest单元测试) - Postman / curl 调
/user/login redis-cli观察 key 与 TTL- 带验证码调
/user/loginVerification拿 token - 用 token 访问受保护接口
- 单独验证短信通道(
- 调试与排查
- 断点调试、日志、Redis 观察、抓包
- 现象 → 可能原因 → 排查动作 排错表
- 经验复盘
- 第三方能力封装、配置外置、短时效数据用 Redis、频控是底线、首次登录即注册
- 抽象出可复用的通用验证路径(Mermaid)
- 工程实况对照:讲义与代码快照的四处出入
要验证什么:六项检查点清单
一条登录验证码链路,任何一个环节挂了,表现都是"登录不成功",但原因完全不同。所以动手之前先把链路拆成可以独立判断的检查点。
链路是这样的:
#mermaid-svg-bApNEr6QiSiqZgVP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bApNEr6QiSiqZgVP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bApNEr6QiSiqZgVP .error-icon{fill:#552222;}#mermaid-svg-bApNEr6QiSiqZgVP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bApNEr6QiSiqZgVP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bApNEr6QiSiqZgVP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bApNEr6QiSiqZgVP .marker.cross{stroke:#333333;}#mermaid-svg-bApNEr6QiSiqZgVP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bApNEr6QiSiqZgVP p{margin:0;}#mermaid-svg-bApNEr6QiSiqZgVP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bApNEr6QiSiqZgVP .cluster-label text{fill:#333;}#mermaid-svg-bApNEr6QiSiqZgVP .cluster-label span{color:#333;}#mermaid-svg-bApNEr6QiSiqZgVP .cluster-label span p{background-color:transparent;}#mermaid-svg-bApNEr6QiSiqZgVP .label text,#mermaid-svg-bApNEr6QiSiqZgVP span{fill:#333;color:#333;}#mermaid-svg-bApNEr6QiSiqZgVP .node rect,#mermaid-svg-bApNEr6QiSiqZgVP .node circle,#mermaid-svg-bApNEr6QiSiqZgVP .node ellipse,#mermaid-svg-bApNEr6QiSiqZgVP .node polygon,#mermaid-svg-bApNEr6QiSiqZgVP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bApNEr6QiSiqZgVP .rough-node .label text,#mermaid-svg-bApNEr6QiSiqZgVP .node .label text,#mermaid-svg-bApNEr6QiSiqZgVP .image-shape .label,#mermaid-svg-bApNEr6QiSiqZgVP .icon-shape .label{text-anchor:middle;}#mermaid-svg-bApNEr6QiSiqZgVP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bApNEr6QiSiqZgVP .rough-node .label,#mermaid-svg-bApNEr6QiSiqZgVP .node .label,#mermaid-svg-bApNEr6QiSiqZgVP .image-shape .label,#mermaid-svg-bApNEr6QiSiqZgVP .icon-shape .label{text-align:center;}#mermaid-svg-bApNEr6QiSiqZgVP .node.clickable{cursor:pointer;}#mermaid-svg-bApNEr6QiSiqZgVP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bApNEr6QiSiqZgVP .arrowheadPath{fill:#333333;}#mermaid-svg-bApNEr6QiSiqZgVP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bApNEr6QiSiqZgVP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bApNEr6QiSiqZgVP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bApNEr6QiSiqZgVP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bApNEr6QiSiqZgVP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bApNEr6QiSiqZgVP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bApNEr6QiSiqZgVP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bApNEr6QiSiqZgVP .cluster text{fill:#333;}#mermaid-svg-bApNEr6QiSiqZgVP .cluster span{color:#333;}#mermaid-svg-bApNEr6QiSiqZgVP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bApNEr6QiSiqZgVP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bApNEr6QiSiqZgVP rect.text{fill:none;stroke-width:0;}#mermaid-svg-bApNEr6QiSiqZgVP .icon-shape,#mermaid-svg-bApNEr6QiSiqZgVP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bApNEr6QiSiqZgVP .icon-shape p,#mermaid-svg-bApNEr6QiSiqZgVP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bApNEr6QiSiqZgVP .icon-shape .label rect,#mermaid-svg-bApNEr6QiSiqZgVP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bApNEr6QiSiqZgVP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bApNEr6QiSiqZgVP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bApNEr6QiSiqZgVP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} App 提交手机号
LoginController /user/login
UserService.sendMsg
生成 6 位验证码
SmsTemplate 调阿里云
写入 Redis CHECK_CODE_phone
用户收到短信
提交验证码 /user/loginVerification
Redis 取出并比对
查库 tb_user
不存在则注册
签发 JWT token
对应到验证动作:
| 检查点 | 怎么验 | 预期结果 | 不通过说明什么 |
|---|---|---|---|
| 接口连通性 | Postman POST http://localhost:18080/user/login,body 为 {"phone":"..."} |
状态码 200,返回体为空 | 404 是路径或端口错;400 是 body 不是合法 JSON;415 是漏了 Content-Type: application/json |
| 短信是否发出 | 看控制台打印的阿里云返回 Message,同时盯手机 |
Message = OK,手机秒级收到 |
返回签名/模板相关错误 → 签名或模板没审核通过;isv.MOBILE_NUMBER_ILLEGAL → 号码不在测试白名单 |
| 验证码是否落 Redis | redis-cli 下 keys CHECK_CODE_*、get、ttl |
存在 CHECK_CODE_手机号,TTL 约 300 秒 |
key 不存在 → Service 没走到第 3 步或 Redis 连的不是同一台;key 有但 ttl = -1 → 写的时候漏了过期时间 |
| 验证码比对逻辑 | 先用错误码调一次,再用正确码调一次 | 错误码被拒、正确码返回 token | 错误码也能过 → 比对条件写反;正确码被拒 → 取 key 的前缀和写 key 的前缀不一致 |
| 首次登录即注册 | 用未注册手机号走完流程,再查库 | tb_user 多出一条记录 |
返回成功但库里没数据 → Dubbo 提供者没起、或事务/唯一索引把插入拦了 |
| 频控生效 | 连续快速调 /user/login 多次 |
超过阈值后被拦截并给出提示 | 完全拦不住 → 频控没做或 key 维度写错;自己测试时被拦 → 需要给测试号码放行或清 key |
这张表可以直接当验收清单用。后面做上传头像、发布动态时,把中间件那一行换成 MinIO 或 MongoDB,思路原样复用。
端到端验证的完整步骤
先单独验证短信通道
端到端一起测,出了错你不知道是短信 SDK 的问题还是业务代码的问题。先用一个最小单元测试把短信通道单独打穿。
mf-app-server/src/test/java/com/wb/test/ 下放:
java
package com.wb.test;
import com.mf.autoconfig.template.SmsTemplate;
import com.mf.server.AppServerApplication;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class SmsTemplateTest {
// 注入自动装配注册进来的短信模板对象
@Autowired
private SmsTemplate smsTemplate;
// 单测只验证"能不能发出去",不关心业务
@Test
public void testSendSms() {
smsTemplate.sendSms("18618412321", "4567");
}
}
跑之前确认两件事:yml 里的 mf.sms 四项配全了,SmsTemplate 的包路径是 com.mf.autoconfig.template(单数 template,讲义里写成 templates 是复数,按工程里实际的写,否则 import 直接报红)。测试跑通、手机收到短信,说明 SDK、签名、模板、AK/SK 都是对的,后面再出问题就往业务侧找。
这里有个必须记住的前提:测试用的签名和模板只对绑定过的测试号码生效。"我用自己的号收不到"最常见的不是代码错,而是这个号码根本没加进阿里云控制台的测试号码列表。
用 Postman 或 curl 调登录接口
启动 AppServerApplication(端口 18080,来自 application.yml),然后发请求。curl 版本:
bash
curl -X POST 'http://localhost:18080/user/login' \
-H 'Content-Type: application/json' \
-d '{"phone":"18618412321"}' \
-i
Postman 里对应的是:方法 POST,URL http://localhost:18080/user/login,Body 选 raw → JSON,内容同上。注意 Body 类型别选成 form-data,那是另一个坑。
看这段时留意位置:/user/login 这个路径不是接口上的完整写法,/user 来自类上的 @RequestMapping("/user"),/login 来自方法上的 @PostMapping("/login"),拼起来才是。
java
package com.mf.server.controller;
import com.mf.server.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private UserService userService;
/**
* 获取登录验证码
* 请求参数:phone
* 响应:void
*/
@PostMapping("/login")
public ResponseEntity login(@RequestBody Map map) {
String phone = (String) map.get("phone");
userService.sendMsg(phone);
return ResponseEntity.ok(null);
}
/**
* 校验登录
*/
@PostMapping("/loginVerification")
public ResponseEntity loginVerification(@RequestBody Map map) {
// 1、从请求体取手机号和验证码
String phone = (String) map.get("phone");
String code = (String) map.get("verificationCode");
// 2、调用 service 完成校验与登录
Map retMap = userService.loginVerification(phone, code);
// 3、构造返回
return ResponseEntity.ok(retMap);
}
}
第一步调完,期望的响应是这样的:
json
HTTP/1.1 200
Content-Length: 0
没有任何返回体是正常的,接口本身只负责"把短信发出去"。状态码 200 表示通讯成功,不代表短信发成功了------短信好不好使,得看下一节。
去 Redis 里看验证码
这是这套流程里唯一能"看见中间状态"的地方,务必养成习惯。
bash
# 连接 Redis(IP 以 yml 里 spring.redis.host 为准)
redis-cli -h 192.168.136.160 -p 6379
# 看有哪些验证码 key(量少时够用)
keys CHECK_CODE_*
# 看某条验证码的值和剩余存活时间
get CHECK_CODE_18618412321
ttl CHECK_CODE_18618412321
预期 get 出一串 6 位数字(和手机收到的一致),ttl 在 300 秒以内递减。
ttl 是这套验证里最值钱的一个命令,它的三种返回值要分清:
| ttl 返回值 | 含义 | 判断 |
|---|---|---|
| 0 ~ 300 | 正常设置了过期时间 | 通过 |
| -1 | key 存在但没有过期时间 | bug,写入时漏了 Duration 参数,这条验证码会永久有效 |
| -2 | key 不存在 | 要么真的没写进去,要么已经被 5 分钟自动过期清掉了 |
生产环境的提醒:keys 命令是 O(N) 遍历,会阻塞 Redis 单线程,数据量大了直接拖垮服务。生产上用 scan 游标遍历:
bash
redis-cli -h 192.168.136.160 --scan --pattern 'CHECK_CODE_*' | head
--scan 不是一次扫全库,而是分批返回游标,不会长时间阻塞。开发期数据少,用 keys 图个快没问题,但要清楚这是坏习惯。
带着验证码完成登录
把上一步 get 到的验证码填进请求:
bash
curl -X POST 'http://localhost:18080/user/loginVerification' \
-H 'Content-Type: application/json' \
-d '{"phone":"18618412321","verificationCode":"417733"}'
期望返回:
json
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTA3LCJtb2JpbGUiOiIxODYxODQxMjMyMSIsImV4cCI6MTYxODM4MzU5OX0.xxxx",
"isNew": true
}
isNew = true 说明这是个新用户,走的是"首次登录即注册"分支。验证方式很直接:
sql
-- 先按手机号查,验证注册是否落库
SELECT id, mobile, password, created, updated
FROM tb_user
WHERE mobile = '18618412321';
查得到、且 password 是 123456 的 MD5 值(e10adc3949ba59abbe56e057f20f883e),说明注册分支正常。再跑一次同一手机号,isNew 应该变成 false,库里不会多出第二条记录------这一步同时验证了"重复登录不会重复注册"。如果表里出现了两条同号记录,说明 mobile 上的唯一约束缺失(建表语句里 mobile 只有一个普通索引 KEY mobile (mobile),不是唯一索引,靠业务逻辑兜底,这点要注意)。
验证 token 能不能用
拿到 token 后,别急着往下写。用任意一个需要登录才能访问的接口带 token 请求一次:
bash
curl -X GET 'http://localhost:18080/users' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.yyy'
能正常返回、且不带 token 时被拦下来 ,才算把"登录闭环"验完。只测"能拿到 token"是半截验证------token 能不能被识别、什么时候过期、后续接口怎么从 token 里取 id,这些是后面统一鉴权要处理的事,这里先把引子留出来。
手机端原型的交互约束
验证码这套功能前端也承担了一半校验,看原型就清楚:
doc
+--------------------------------------+
| 项目 |
| [logo] |
| 项目 slogn |
| |
| +--------------------------------+ |
| | 请输入手机号 |获取验证码| |
| +--------------------------------+ |
| |
| +--------------------------------+ |
| | 输入手机验证码 |重新发送 50s| |
| +--------------------------------+ |
| |
| 手机号登录注册 |
| |
| 提示:已发送至 135****1111 |
+--------------------------------------+
要点:
- 手机号位数在前端先校验,位数不对时输入框标红并提示"请输入正确的手机号",请求不发出去。所以后端测到"号码非法"时,先确认是不是绕过前端直接调的接口。
- 验证码错误时输入框抖动、内容清空并标红,提示"验证码错误"。这是前端对 500/401 状态码的一种消费方式。
- "重新发送"按钮有 50 秒倒计时。这是前端频控,和 Redis 侧的服务端频控是两层,别以为前端有倒计时后端就不用做。
- 原型里的手机号
13511111111是示意值,实际测试要用阿里云控制台绑定过的号码。
调试与排查手段
断点调试
在 LoginController.login 的第一行和 UserService.sendMsg 的三处关键位置各打一个断点,用 Debug 模式启动,然后用 Postman 发请求:
- 断在 Controller:看
map里的phone是不是真的取到了。@RequestBody Map map拿不到值,八成是请求头不是application/json------这种问题打印日志也看不出来,只能看变量。 - 断在
sendMsg里生成验证码之后:看code是不是 6 位数字字符串。 - 断在
redisTemplate.opsForValue().set(...)之前:看 key 拼出来是CHECK_CODE_18618412321还是少了前缀。 - 断在
smsTemplate.sendSms里response.getBody()那一行:这是判断"短信到底发没发出去"的唯一权威点,看body.getMessage()是OK还是错误码。
变量面板看的是当前栈帧的真实值,比在代码里到处 System.out.println 可控得多。
日志排查
SmsTemplate 里目前是 System.out.println(body.getMessage()),排查期够用,正式点应该换成 @Slf4j 打 log.info("短信发送结果:{}", body.getMessage())。要看的日志点:
- 阿里云返回的
Code与Message; - Redis 写入前后(写 key 时不要把验证码明文打出来,生产日志会落盘、会被采集,等于把验证码公开了,要打就打掩码);
- 用户注册分支是否走到("新用户注册 mobile=186****2321 userId=107")。
Redis 观察
前面已经给了命令。补一个容易忽略的点:如果 Controller 里返回 200、日志也没报错,但 keys CHECK_CODE_* 是空的,先确认应用连的 Redis 和你 redis-cli 连的是不是同一个实例 。yml 里 host 是 192.168.136.160,本地要是起了个自己的 Redis,默认又去连了 127.0.0.1:6379,就会出现"程序觉得写成功了、你查却查不到"的假象。一句话确认:
bash
redis-cli -h 192.168.136.160 info server | grep redis_version
抓包与看请求
- Postman 的响应面板直接给状态码、耗时、响应头和 body,调接口阶段用它最省事。
- 真机或模拟器上跑,用浏览器的 Network 面板或者 Charles / Fiddler 看 App 实际发出去的请求体和 Header,能发现"参数名写成
verificationcode"这类大小写问题------map.get("verificationCode")大小写不匹配会直接拿到 null。
排错表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 接口返回 404 | 路径写错(少写 /user)、端口不是 18080、应用没起在预期端口 |
看启动日志里的 Tomcat started on port(s),再确认完整路径 |
| 返回 400 或 phone 为 null | Body 用了 form-data、JSON 字段名写成 mobile、漏了 Content-Type |
Postman 里把 Body 切到 raw+JSON,字段名对齐 phone |
| 接口 200 但手机收不到短信 | 号码不在测试白名单、签名/模板未审核、运营商拦截 | 看控制台打的阿里云 Message,再核对控制台签名与模板状态 |
| Redis 里查不到 key | 没走到写入、连错 Redis 实例、验证码已过期 | redis-cli 确认 host,Debug 断在 set 那行 |
key 有但 ttl = -1 |
写入时漏了 Duration.ofMinutes(5) |
检查 opsForValue().set 是否带了过期参数 |
| 手机收得到码,提交后端说验证码错误 | 前后端 key 前缀不一致、值被 trim、验证码过期 | 打印两边实际取值比对,确认都用 CHECK_CODE_ 前缀 |
| 测试时被自己的频控拦住 | 同一号码短时间内重复请求 | 换号、清掉频控 key,或临时给测试号码加白名单 |
返回成功但 tb_user 没有新用户 |
Dubbo 提供者未启动、save 的 id 回填失败、插入被约束拦下 |
看消费者日志是否有 No provider available,再查提供者日志 |
第一次登录 isNew=true,第二次还是 true |
查库条件错、findByMobile 查的是别的表 |
断点看 userApi.findByMobile 返回值 |
从这次功能里沉淀的经验
几个不管换什么项目都成立的结论。
第三方能力一定要封装。 短信 SDK 直接写在 UserService 里,业务代码就绑死了阿里云;换成封装后的 SmsTemplate,业务只依赖一个能力接口,换供应商时改动面很小。这条和前面自动装配那一节是同一件事的两种说法。
配置必须外置。 AK/SK 这类密钥写死在代码或直接提交进仓库,等于公开泄露。项目里 application.yml 的 mf.sms 节点给了一个位置,但这份 yml 本身也不该进 Git------正确做法是本地保留真实的、仓库里只提交一份带占位符的样例,生产环境用环境变量或配置中心注入。快照代码里 AK/SK 是明文,学习环境无所谓,自己的项目别这么干。
验证码这类短时效数据天然适合 Redis。 5 分钟过期,Duration.ofMinutes(5) 一句就交代完,不需要清理任务、不需要定时扫表。这是"用对中间件"带来的复杂度削减,比在 MySQL 里加 expire_time + 定时任务清爽得多。
频控不是可选项。 短信是按条计费的。没有频控,一个脚本就能把账户余额刷光,顺带把别人的手机号骚扰一遍。测试环境同样会扣费,"测试环境随便刷"是个误解。
首次登录即注册是产品与技术的联合设计。 用户不用先注册再登录,路径短了;代价是后端要保证默认值填充正确,并且在 mobile 上加唯一约束兜底并发场景------代码里的注册分支是"先查后插",两个请求同时进来可能出现两个新用户,这是已知的检查点。
一条可以复用的验证路径
把上面的动作抽象一下,后面所有涉及多层的功能验证都可以套这个顺序:
#mermaid-svg-WJSjDukIuOFaTtuD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WJSjDukIuOFaTtuD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WJSjDukIuOFaTtuD .error-icon{fill:#552222;}#mermaid-svg-WJSjDukIuOFaTtuD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WJSjDukIuOFaTtuD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WJSjDukIuOFaTtuD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WJSjDukIuOFaTtuD .marker.cross{stroke:#333333;}#mermaid-svg-WJSjDukIuOFaTtuD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WJSjDukIuOFaTtuD p{margin:0;}#mermaid-svg-WJSjDukIuOFaTtuD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WJSjDukIuOFaTtuD .cluster-label text{fill:#333;}#mermaid-svg-WJSjDukIuOFaTtuD .cluster-label span{color:#333;}#mermaid-svg-WJSjDukIuOFaTtuD .cluster-label span p{background-color:transparent;}#mermaid-svg-WJSjDukIuOFaTtuD .label text,#mermaid-svg-WJSjDukIuOFaTtuD span{fill:#333;color:#333;}#mermaid-svg-WJSjDukIuOFaTtuD .node rect,#mermaid-svg-WJSjDukIuOFaTtuD .node circle,#mermaid-svg-WJSjDukIuOFaTtuD .node ellipse,#mermaid-svg-WJSjDukIuOFaTtuD .node polygon,#mermaid-svg-WJSjDukIuOFaTtuD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WJSjDukIuOFaTtuD .rough-node .label text,#mermaid-svg-WJSjDukIuOFaTtuD .node .label text,#mermaid-svg-WJSjDukIuOFaTtuD .image-shape .label,#mermaid-svg-WJSjDukIuOFaTtuD .icon-shape .label{text-anchor:middle;}#mermaid-svg-WJSjDukIuOFaTtuD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WJSjDukIuOFaTtuD .rough-node .label,#mermaid-svg-WJSjDukIuOFaTtuD .node .label,#mermaid-svg-WJSjDukIuOFaTtuD .image-shape .label,#mermaid-svg-WJSjDukIuOFaTtuD .icon-shape .label{text-align:center;}#mermaid-svg-WJSjDukIuOFaTtuD .node.clickable{cursor:pointer;}#mermaid-svg-WJSjDukIuOFaTtuD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WJSjDukIuOFaTtuD .arrowheadPath{fill:#333333;}#mermaid-svg-WJSjDukIuOFaTtuD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WJSjDukIuOFaTtuD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WJSjDukIuOFaTtuD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WJSjDukIuOFaTtuD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WJSjDukIuOFaTtuD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WJSjDukIuOFaTtuD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WJSjDukIuOFaTtuD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WJSjDukIuOFaTtuD .cluster text{fill:#333;}#mermaid-svg-WJSjDukIuOFaTtuD .cluster span{color:#333;}#mermaid-svg-WJSjDukIuOFaTtuD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WJSjDukIuOFaTtuD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WJSjDukIuOFaTtuD rect.text{fill:none;stroke-width:0;}#mermaid-svg-WJSjDukIuOFaTtuD .icon-shape,#mermaid-svg-WJSjDukIuOFaTtuD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WJSjDukIuOFaTtuD .icon-shape p,#mermaid-svg-WJSjDukIuOFaTtuD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WJSjDukIuOFaTtuD .icon-shape .label rect,#mermaid-svg-WJSjDukIuOFaTtuD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WJSjDukIuOFaTtuD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WJSjDukIuOFaTtuD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WJSjDukIuOFaTtuD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 任一步失败
启动依赖服务
MySQL / Redis / Nacos / Dubbo
先验接口
路径 参数 状态码
再验中间件数据
Redis key / MQ 消息 / 缓存
再验数据库落库
SELECT 校验记录与字段
再验异步链路
消费者是否消费 回调是否到达
最后验返回侧
token 可用 前端能消费
按该层排查
不要跨层猜
顺序不能乱。先验接口是因为接口不通,后面全是对着空气调试;中间件先于数据库,是因为 Redis 的写入发生在前,能最快定位是"没写进去"还是"写进去但落库失败"。
upload、发布动态这些功能无非是换了个中间件(MinIO、MongoDB),骨架一模一样。
工程实况对照:讲义与代码快照的四处出入
验证时手上要打开的文件就这么几个,先认一下位置:
tree
mf
├── mf-app-server # 应用层,端口 18080,本篇的接口都在这里
│ ├── src/main/java/com/mf/server
│ │ ├── AppServerApplication.java # 启动类
│ │ ├── controller/LoginController.java # /user/login、/user/loginVerification
│ │ └── service/UserService.java # 生成验证码、写 Redis、校验登录
│ ├── src/main/resources/application.yml # 端口、Redis、Nacos、短信配置
│ └── src/test/java/com/wb/test
│ └── SmsTemplateTest.java # 单独验证短信通道的最小单测
├── mf-autoconfig # 自动装配层
│ └── com/mf/autoconfig
│ ├── mfAutoConfiguration.java
│ ├── properties/SmsProperties.java # 映射 mf.sms 配置
│ └── template/SmsTemplate.java # 封装阿里云短信 SDK
└── mf-dubbo
└── mf-dubbo-db/com/mf/dubbo/api/UserApiImpl.java # findByMobile / save
读讲义 4.4 和同期代码快照时,有四处不一致,动手前先对齐,否则会莫名报红:
| 位置 | 讲义写法 | 代码快照写法 | 以谁为准 |
|---|---|---|---|
SmsTemplate 包名 |
com.mf.autoconfig.templates(复数) |
com.mf.autoconfig.template(单数) |
以工程实际为准,按快照的写 |
| Dubbo 注入注解 | @Reference |
@DubboReference |
看依赖版本,快照里用的是 @DubboReference |
| Controller 返回 | 直接 return userService.sendMsg(mobile); |
userService.sendMsg(phone); return ResponseEntity.ok(null); |
两种都能跑,快照把返回构造收敛到 Controller |
sendMsg 实现 |
生成随机码并真实发短信 | 验证码写死 "123456",template.sendSms 被注释 |
快照是调试降级版,上线前必须改回随机码 + 真实发送 |
最后一条尤其要留意。把验证码写死成 123456 并跳过短信发送,是本地联调时省短信费、绕开手机号的常用手段,但它是一个巨大的安全口子------如果带着这段代码上线,任何手机号都能用 123456 登录。这类"临时改一行方便测试"的改动,交付前一定要有一遍专门的回查。
常见误解澄清
- "短信没收到 = 代码有问题"。排除了白名单、签名模板审核、运营商拦截这三样之后,才轮到看代码。
- "Redis 里有 key 就万事大吉" 。key 在但
ttl = -1,验证码永久有效,问题比没有 key 还大。 - "接口返回 200 就代表流程完整"。200 只说明 HTTP 链路通,数据是否落库、短信是否送达都要单独验。
- "测试环境可以随便刷短信"。一样计费,一样要频控。
- "前端有校验就够了"。前端校验只是为了少发一次请求,后端必须独立再校验一遍手机号格式和验证码,绕过前端直接调接口是最基本的攻击方式。
官方文档
总结
这套流程的验证逻辑其实只有一句话:每一层都要能单独看见它的状态。接口看状态码,短信看阿里云返回码,Redis 看值和 TTL,数据库看记录,token 看能不能过鉴权。把这五层拆开,问题只会出现在某一层里,排查从"猜"变成了"看"。
代码侧的收尾也就三件事:验证码生成后发出去、同时落到 Redis 并带上 5 分钟过期、返回值用 ResponseEntity 包一层好控制状态码。真正决定质量的是配置有没有外置、频控有没有做、调试期的临时改动有没有回查。
下一篇从这里往下走,进入 JWT------登录校验通过之后,token 是怎么生成、怎么被后续接口识别的。