Java 项目实战: 交友项目 - 登录发送验证码的测试验证与经验复盘

概述

接口写完了不等于流程跑通了。这一篇不重复接口怎么设计、代码怎么写,只解决一件事:怎么用一套可复现的步骤证明"发验证码 + 登录"这条链路真的通了,以及出问题时该看哪里。

纲要

  • 验证清单
    • 六项检查点:接口连通性 / 短信是否真发出 / 验证码是否落 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 是怎么生成、怎么被后续接口识别的。

相关推荐
夕除1 小时前
redis--主从还是集群,内存
java·spring·mybatis
百数平台1 小时前
低代码安全技术实践:表单 / 流程 / 报表三级权限体系设计解析
java·大数据·数据库
白露与泡影1 小时前
Spring Boot 实现验证码:Hutool-captcha、AJ-Captcha、EasyCaptcha 到底怎么选?
java·spring boot·后端
马优晨1 小时前
Java中的dto是什么
java·开发语言·java中的dto是什么·java的dto是什么·dto在java中是什么
小蒜学长1 小时前
基于Springboot+Vue的高校教师教学业绩综合管理系统的设计与开发(代码+数据库+LW)
java·数据库·spring boot·后端·教学业绩管理·三级权限
Mikko71 小时前
Spring Boot 3.5.16 自带的 Tomcat 10.1.55 中了哪些 CVE?其中一条是修上一个漏洞时带进来的
java·spring boot·后端·安全·tomcat
十年Java程序媛1 小时前
Java 内部类全解|成员 / 静态 / 匿名内部类,理清和 Lambda 的区别
java·spring boot·面试
itmigrate1 小时前
PHP 导出大 CSV 时内存溢出的一次排查
android·开发语言·php
SL_staff2 小时前
报工≠打卡:从任务报工到闭环管控的5个数据断点排查清单
java·spring boot·全栈