最近在开源项目(nest-admin)里给注册模块做了一次升级,起因很简单:注册页一直只有图片算术验证码,想加一种更正式的方式——填邮箱,系统通过 SMTP 自动发一串验证码,凭码完成注册。
要求也提得很明确:原图片验证码不动(兼容),邮箱发码只是配置了才启用的增强项;间隔要处理好,前端拦一道、后端再拦一道,免得发多了触发邮件服务商的风控。
借着这次改造,把整套设计、踩坑和验证方法整理下来,算是给以后接手代码的同学留个说明书,也是一次完整功能的经验记录。
正文
问题总览
要解决的问题其实就一句话:注册时验证「你声称的邮箱是你的」。拆开是四件事:
- 发码:SMTP 自动发 6 位验证码,5 分钟有效,一次性使用;
- 降级:没配置 SMTP 的部署,注册页保持原样(图片验证码),完全无感;
- 校验:注册接口按配置走邮箱验证码或图片验证码之一,不能混着钻空子;
- 防刷:发码间隔层层收紧,别把发件账号玩进服务商的黑名单。
设计上有两个前置决策先定下来,后面所有东西都挂在这两根钉子上:
| 决策 | 选择 | 理由 |
|---|---|---|
| SMTP 配置放哪里 | 服务端 yml,不进数据库 | 授权码是敏感凭据,参数管理表里任何有查询权限的管理员都看得见 |
| 启用判定是什么 | enabled 且三项配置齐全 |
不搞「配置了就默认开启」的隐式行为,开关显式、好排查 |
改造前后对比
| 对比项 | 改造前 | 改造后(配置了 mail) | 改造后(未配置 mail) |
|---|---|---|---|
| 注册页验证方式 | 图片算术验证码 | 邮箱 + 验证码输入框 + 「获取验证码」倒计时按钮 | 与改造前一致 |
| 验证介质 | Redis captcha_codes:<uuid> |
Redis email_code:<邮箱> |
同左 |
| 邮箱落库 | 不涉及 | sys_user.email 写入并做占用查重 |
不涉及 |
| 发码防刷 | 无(本来就不发码) | 冷却 + 双限流,见下文 | 不触发 |
| 登录验证码 | 按账号失败次数动态出现 | 零改动 | 零改动 |
服务端:MailService 与配置
配置走 yml 的 mail: 段,和仓库里 mysql/redis/jwt 的模式保持一致,支持环境变量覆盖:
1 | mail: |
isEnabled() 是全功能的总开关:enabled === true 且 host/user/pass 三项齐全。前端通过公开接口 GET /register/config 拿到这个布尔值,决定渲染哪种表单。
SMTP 凭据这类东西,注入方式有个隐藏坑——项目里有两个叫 ConfigService 的服务:@nestjs/config 的(读 yml)和系统参数的(读数据库)。MailService 要的是前者,注入时 import 来源写错拿到的就是空配置。
最大的坑:IPv6 直连撞死
联调真实 QQ SMTP 时,第一次发送直接报 ENETUNREACH 240e:...:465——连接的是 IPv6 地址。
排查下来是三层因素叠出来的:
- 本机网卡有 IPv6 地址(link-local / ULA),但没有全局可路由的 v6 出口;
- nodemailer 10 的双栈解析把 IPv6 排在前面(RFC 8305 风格);
- 它的回退靠
net.connect的autoSelectFamily(Happy Eyeballs),而 Node 18 根本不支持这个选项(Node 20+ 才有),静默忽略后就成了单地址硬连。
修复放在 MailService 里,建 transport 前优先解析 IPv4 直连,SNI 和证书校验仍用原域名;纯 IPv6 环境解析失败就回退原逻辑:
1 | if (opts.host && !net.isIP(opts.host)) { |
注意 tls.servername 不能省:host 换成了 IP,不带 servername 的话 TLS 握手拿 IP 做 SNI,*.mail.qq.com 的证书校验直接挂。
另一个小坑顺手记在这里:配置工厂把布尔项读出来时用的是 String(env || yml || true),|| 链会让 yml 里显式写的 secure: false 被兜底值 true 覆盖——布尔配置必须用 ?? 链。
发码端点与四层防刷
POST /register/emailCode 的完整流程:
1 | POST /register/emailCode { email } |
四层防刷各自要防的场景不一样:
| 层级 | 防什么 |
|---|---|
| 邮箱占用查重 | 给已注册邮箱发码骚扰用户 |
| 同邮箱 60s 冷却 | 手抖连点、脚本对同一收件箱高频发码 |
| 同邮箱每小时 10 封 | 换着验证码轰炸单个收件箱——这是最容易触发服务商风控的场景 |
| 同 IP 每小时 10 次 | 单个来源批量给不同邮箱发码(注册机行为) |
有一个设计点值得单独说:SMTP 发送失败时不占冷却。冷却键是发送前 incr 的(保护 SMTP 不被 hammering),发送异常就把键删掉,用户可以立即重试;但每小时上限和 IP 限流照常计数——失败重试也该有闸门,只是不该让正常用户干等。
429 的报错文案直接带精确剩余秒数(读 Redis TTL 算出来):
1 | { "code": 429, "msg": "验证码发送过于频繁,请 59 秒后再试" } |
注册校验的双模式改造
注册原来是方法装饰器 @Captcha('user', 'always') 一刀切,现在要按配置二选一,装饰器的静态特性就不合适了,改成了方法体开头的显式分支:
1 | private async validateRegisterVerification(user: RegisterDto): Promise<ResultData | null> { |
两种验证码的作废时机是有意不一样的:
- 图片验证码:校验通过立即删。反正刷新一下就有新的,用户无感;
- 邮箱验证码:注册成功才删。填错账号名、密码强度不符这类失败,用户修完字段凭原码直接再提交,不用回邮箱收第二封。安全性没有缺口——验证码绑定了邮箱,换邮箱对不上号,同邮箱再注册会被占用查重拦住。
RegisterDto 加了可选的 email/emailCode,LoginDto 一个字没动。全局 ValidationPipe({ whitelist: true }) 会剥掉未声明的字段,所以 DTO 是前后端的契约边界,漏声明等于没传。
前端:双模式表单与倒计时
注册页挂载时先请求 /register/config,按 emailRegisterEnabled 切换两套表单(v-if / v-else,和登录页按 captchaEnabled 切换验证码是同一个模式);邮箱模式下连图片验证码请求都不发,配置获取失败按传统模式兜底。
倒计时做在 useEmailCode 组合式函数里,核心是把冷却时间戳按邮箱持久化到 localStorage:
1 | const startCountdown = (email: string) => { |
不持久化的后果很现实:用户发完码 F5 一下,按钮就活了——虽然后端会拦,但「按钮可点、点了报错」本身就是一次糟糕的体验,前端这道闸就该把刷新也挡住。
前后端两道拦截的对应关系:
| 层级 | 手段 | 挡住什么 |
|---|---|---|
| 前端 | 按钮倒计时禁用 + localStorage 持久化 | 手抖连点、刷新绕过 |
| 后端 | 60s 冷却 + 双限流 | 一切绕过前端的手段 |
前端只做体验层,安全语义永远以后端为准——时间戳在客户端,改起来毫无成本。
还有一个只有写出来才记得住的坑:把校验规则从静态对象改成 computed(按模式切换 rules)后,element-plus 的 el-form 默认 rules 一变就全量校验(validateOnRuleChange 默认 true),配置接口返回的那一瞬间整页表单红错。加一行 :validate-on-rule-change="false" 解决。以及 watch(() => registerForm.email, ...) 必须写在 registerForm 声明之后——watch 创建时就求值 getter,放前面直接 TDZ 白屏。
怎么验证
联调真实 SMTP 前先造一个本地假 SMTP 服务器,十几行 net 代码,能完整跑通 nodemailer 的握手、AUTH、DATA,还能从日志里直接 grep 出验证码对正文:
1 | // 收到 EHLO 回双行 250,AUTH 用 334/235,DATA 后逐行打印直到 "." |
用它在 8081 测试实例上验证了降级、DTO 校验、发送失败兜底、60s 冷却、邮箱占用拦截、错码不消费、成功才作废、email 落库——整套接口链不用真实邮箱就能跑。
最后才上真实 QQ SMTP,对着自己的发件邮箱发一封,收件箱里的验证码和 Redis 里存的对上号,全链路才算闭环。这步顺序别反:直接拿真实邮箱当小白鼠,发失败一次就是给发件账号的风控记录攒一笔。
已知的取舍
有几个点是有意为之的,写下来免得以后自己都要怀疑人生。
- 验证码 6 位、5 分钟,不加尝试次数限制。爆破窗口理论上存在,但注册接口本身有 IP 限流(10 次/时)压着,且发码四层拦截已经把获取验证码的成本抬得很高,不值得再堆一层计数器。
- 429 的剩余秒数前端拿不到。响应拦截器对业务错误统一 reject,信封里的 data 带不出来。靠 localStorage 倒计时兜底后,能碰到 429 的只剩多开标签页的场景,弹个统一文案可以接受。真要同步就得动全局拦截器,不值当。
- sys_user.email 不建唯一索引。存量用户的 email 全是空串,多个
''直接撞唯一约束。应用层查重 + 发码前置拦截已经够用,索引留给以后数据干净了再加。 - 邮件正文就是一段内联 HTML。没有模板引擎、没有多语言。注册验证码这个场景十年也变不了一次文案,引擎纯属负重。
结语
验证这套功能是否完整,对着这张清单过一遍就行:
- 不配 mail 启动:注册页与改造前逐像素一致,登录不受影响;
- 配置 mail 重启:注册页出现邮箱输入框和倒计时按钮,图片验证码消失;
- 发码 → 收件箱验码 → 凭码注册 → 查库 email 已写入、Redis 键已删;
- 60 秒内再点发送:按钮是灰的;用 curl 绕过前端:429 带剩余秒数;
- 发完码刷新页面:倒计时还在走。
邮箱发码这个功能,写代码本身从来不是难点,真正的功夫全在「间隔」两个字上——前端一道、后端一道,失败怎么办、过期怎么办、被刷怎么办,每一道闸门背后都是服务商风控和用户体验的拉扯。想清楚这些再动手,代码反而是最简单的部分。
如果后面要支持找回密码,发码逻辑可以原样复用(换个场景前缀的 Redis key 和邮件文案),应该会是下一个顺手摘的果子。
另外,感谢 ZCode 提供的 GLM 模型,从探索代码库、写实现到本地 SMTP 仿真联调,原本预计要好几天打磨的完整功能,一个晚上就走完了全链路,效率相当离谱。
参考链接
本篇正文由 ZCode 的 AI 助手协助整理编辑(记录与技术方案源于本人的实际项目改造经验,AI 负责结构梳理与文稿润色)。