摘要:本文讲解如何用 Java + Spring Boot 实现邮箱验证码功能。文章先介绍邮箱验证码的实现原理(带有效期的随机凭证 + Redis 存储),再前置准备(申请邮箱授权码、安装 Redis),随后进行项目依赖与配置,并完整实现邮件发送服务、验证码服务与控制器,最后附上 HTML 邮件模板。
(最简单实现,把本篇文章连接发给AI,只需看第二章即可,交给AI来完成)
一、实现原理:邮箱验证码是怎么工作的
邮箱验证码的本质是一个带有效期的随机凭证。服务端生成一个 6 位数字,通过 SMTP 邮件发到用户邮箱,同时在 Redis 存一份(带过期时间)。用户输入验证码后,服务端拿本地存储的值和用户输入做对比,一致则验证通过,然后删除验证码(一次性使用)。
1.1 核心流程图

1.2 为什么用 Redis
自带过期机制
Redis 的 SETEX / EXPIRE 命令天然支持 TTL,验证码到期自动删除,不需要写定时任务清理。
性能高
验证码读写是高频操作,Redis 纯内存读写速度是数据库的数百倍,不会成为系统瓶颈。
支持分布式
如果应用部署了多台实例,本地 HashMap 无法共享。Redis 作为中心化存储,多实例都能读写同一份验证码。
原子操作
Redis 的 INCR、SETNX 等原子命令可以轻松实现冷却期计数、防重复发送等限流逻辑,不用担心并发问题。
1.3 SMTP 协议简介
SMTP(Simple Mail Transfer Protocol)是电子邮件发送的标准协议,默认端口 25(明文)、465(SSL 加密)、587(STARTTLS 加密)。个人开发者用 465 端口 + SSL 最稳妥。
| 协议 | 作用 | 默认端口 | 加密方式 |
|---|---|---|---|
| SMTP | 发送邮件 | 25 / 465 / 587 | SSL / STARTTLS |
| POP3 | 收取邮件(下载后删除) | 110 / 995 | SSL |
| IMAP | 收取邮件(双向同步) | 143 / 993 | SSL |
授权码 ≠ 邮箱密码
个人邮箱的 SMTP 登录使用的是授权码而不是邮箱密码。授权码权限更低,只能用于发信/收信服务,即使泄露也不会直接危及邮箱账号安全。修改邮箱密码后,授权码会失效,需要重新申请。
二、前置准备:开通 SMTP 与 Redis
2.1 申请邮箱授权码
推荐使用 163 邮箱或 QQ 邮箱,两者申请流程类似,下面以 163 邮箱为例:
登录邮箱,进入设置
打开 163 邮箱网页版,登录后点击顶部「设置」→「POP3/SMTP/IMAP」。

开启 IMAP/SMTP 服务
找到「POP3/SMTP/IMAP」板块,点击「开启」对应的 IMAP/SMTP 服务。按提示用绑定手机发送验证短信。

保存授权码
验证通过后会显示一串授权码(通常 16 位字母)。一定要复制保存好,关闭弹窗后就看不到了,只能重新生成。
注意事项
① 发件地址必须和登录账号一致,否则会报
553 Mail from must equal authorized user;
② 修改邮箱密码后授权码会失效,需要重新申请;
③ 个人邮箱有日发送量限制(通常几百封),生产环境建议换第三方邮件推送服务(需要有域名)。
QQ邮箱类似

2.2 安装 Redis
Windows 下推荐用 Docker :【2026最新】保姆级Docker安装一次搞定
win版安装redis【2026最新】Windows平台Redis安装与优化配置指南
三、统一响应类 Result
在写业务代码之前,先把接口响应格式定下来。所有接口统一返回 {"message": "描述", "code": 0, "data": {...}} 三字段结构,前后端约定一致后,前端处理错误会非常舒服。
3.1 Result 响应类代码
import lombok.Data;
@Data
public class Result<T> {
/** 业务状态码:0 成功,其他为失败 */
private int code;
/** 提示信息 */
private String message;
/** 返回数据 */
private T data;
private Result(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
// ========== 成功响应 ==========
public static <T> Result<T> success() {
return new Result<>(0, "操作成功", null);
}
public static <T> Result<T> success(T data) {
return new Result<>(0, "操作成功", data);
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(0, message, data);
}
// ========== 失败响应 ==========
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
public static <T> Result<T> fail(ErrorCode errorCode) {
return new Result<>(errorCode.getCode(), errorCode.getMessage(), null);
}
}
3.2 错误码枚举
把所有业务错误码集中定义在一个枚举里,方便统一管理和查阅:
import lombok.Getter;
@Getter
public enum ErrorCode {
SUCCESS(0, "操作成功"),
// 验证码相关 400xx
EMAIL_CODE_INVALID(40001, "验证码错误"),
EMAIL_CODE_EXPIRED(40002, "验证码已过期,请重新获取"),
EMAIL_CODE_TOO_MANY_ATTEMPTS(40003, "验证码尝试次数过多,请重新获取"),
// 认证相关 401xx
UNAUTHORIZED(40101, "未登录或登录已过期"),
// 限流相关 429xx
RATE_LIMITED(42901, "请求过于频繁,请稍后再试"),
// 邮件相关 502xx
EMAIL_SEND_FAILED(50201, "邮件发送失败,请稍后重试"),
// 服务不可用 503xx
SERVICE_UNAVAILABLE(50301, "服务暂不可用");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}
3.3 全局异常处理器
用 @RestControllerAdvice 统一捕获异常,转换成 Result 格式返回,这样业务代码里只管抛异常就行:
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 业务异常 */
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(e.getStatus())
.body(Result.fail(e.getCode(), e.getMessage()));
}
/** 参数校验异常 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleValidation(
MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.findFirst()
.orElse("参数校验失败");
return ResponseEntity.badRequest()
.body(Result.fail(40000, message));
}
/** 兜底异常 */
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<Void>> handleGeneral(Exception e) {
log.error("未处理的异常", e);
return ResponseEntity.internalServerError()
.body(Result.fail(50000, "系统内部错误,请稍后重试"));
}
}
3.4 业务异常类
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final int status; // HTTP 状态码
private final int code; // 业务错误码
public BusinessException(int status, ErrorCode errorCode) {
super(errorCode.getMessage());
this.status = status;
this.code = errorCode.getCode();
}
public BusinessException(int status, int code, String message) {
super(message);
this.status = status;
this.code = code;
}
}
四、项目搭建:依赖与配置
4.1 Maven 依赖
在 pom.xml 中加入以下依赖:
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 邮件发送 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok(可选,省掉 getter/setter 样板代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
4.2 配置文件 application.yml
server:
port: 8080
spring:
mail:
host: smtp.163.com # SMTP 服务器
port: 465 # SSL 端口
username: [email protected] # 你的邮箱
password: 你的授权码 # 授权码,不是密码
properties:
mail:
smtp:
auth: true
ssl.enable: true
from: [email protected] # 必须和 username 一致
data:
redis:
host: localhost
port: 6379
timeout: 5s
# 自定义配置
email:
code-ttl-seconds: 300 # 验证码有效期 5 分钟
cooldown-seconds: 60 # 冷却期 60 秒
max-verify-attempts: 5 # 最大错误次数
subject-prefix: "MyApp" # 邮件主题前缀
4.3 配置属性类
把自定义配置绑定成 Java Bean,代码里用起来更安全(有代码提示、拼错字段编译报错):
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "email")
public class EmailProperties {
private int codeTtlSeconds = 300;
private int cooldownSeconds = 60;
private int maxVerifyAttempts = 5;
private String subjectPrefix = "MyApp";
}
五、核心代码实现
代码结构:Controller 接请求 → Service 处理业务逻辑(生成、发信、存 Redis、校验)→ MimeMessageHelper 发 HTML 邮件。
一个邮箱 : 一个有效验证码
Redis 的 key 设计为
email:code:用户邮箱,value 就是验证码。这意味着:
- 新验证码覆盖旧的:用户点「重新发送」后,旧验证码立刻失效,永远只有最新那封邮件里的验证码有效;
- 校验必须对比值:不是 key 存在就算通过,必须拿用户输入的 code 和 Redis 里存的 correctCode 做对比;
- 用一次就删:验证通过后立即从 Redis 删除,防止验证码被重复使用。
5.1 邮件发送服务
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
@Value("${spring.mail.properties.mail.smtp.from:}")
private String from;
/**
* 发送 HTML 邮件
* @param to 收件人
* @param subject 主题
* @param html HTML 正文
*/
public void sendHtml(String to, String subject, String html) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
if (from != null && !from.isBlank()) {
helper.setFrom(from);
}
helper.setText(html, true); // true = 按 HTML 渲染
mailSender.send(message);
} catch (MailException | MessagingException e) {
throw new BusinessException(502, ErrorCode.EMAIL_SEND_FAILED);
}
}
}
5.2 验证码服务
import com.example.common.BusinessException;
import com.example.common.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailCodeService {
private final StringRedisTemplate redisTemplate;
private final EmailService emailService;
private final EmailProperties properties;
// Redis key 前缀:一个邮箱同一时间只有一个有效验证码,用 email 作为 key
private static final String CODE_KEY = "email:code:"; // email:code:[email protected] → 验证码值
private static final String ATTEMPT_KEY = "email:attempt:"; // email:attempt:[email protected] → 错误次数
private static final String COOLDOWN_KEY = "email:cooldown:"; // email:cooldown:[email protected] → 冷却期标记
/**
* 发送验证码
*
* 设计说明:
* 1. 一个邮箱同一时间只有一个有效验证码;
* 2. 重新发送会覆盖旧验证码(set 是覆盖写入),旧的自动失效;
* 3. 冷却期内不允许重复发送,防止刷接口。
*/
public void sendCode(String email) {
// 1. 冷却期检查:60 秒内只能发一次
if (!tryAcquireCooldown(email)) {
throw new BusinessException(429, ErrorCode.RATE_LIMITED);
}
// 2. 生成 6 位随机数字验证码
String code = generateCode();
// 3. 发送邮件
String subject = properties.getSubjectPrefix() + " 注册验证码";
String html = buildHtmlBody(code, properties.getCodeTtlSeconds());
emailService.sendHtml(email, subject, html);
// 4. 写入 Redis:key = email:code:[email protected],value = 验证码
// set 会覆盖旧值,保证一个邮箱只有最新的验证码有效
redisTemplate.opsForValue().set(CODE_KEY + email, code,
properties.getCodeTtlSeconds(), TimeUnit.SECONDS);
// 5. 重置错误次数(新验证码 = 新的 5 次尝试机会)
redisTemplate.delete(ATTEMPT_KEY + email);
}
/**
* 校验验证码
*
* 校验步骤(顺序很重要):
* 1. 取该邮箱当前有效的验证码 → 不存在 = 已过期 / 从未申请
* 2. 检查错误次数 → 超过上限直接拒绝,防止穷举
* 3. 恒时比较验证码值 → 不相等 = 验证码错误,错误次数 +1
* 4. 验证通过 → 立即删除验证码(一次性使用)
*/
public void verify(String email, String code) {
String key = CODE_KEY + email;
// 第 1 步:取出该邮箱当前有效的验证码
String correctCode = redisTemplate.opsForValue().get(key);
if (correctCode == null) {
throw new BusinessException(400, ErrorCode.EMAIL_CODE_EXPIRED);
}
// 第 2 步:检查错误次数,超过上限直接作废
long attempts = getAttemptCount(email);
if (attempts >= properties.getMaxVerifyAttempts()) {
throw new BusinessException(400, ErrorCode.EMAIL_CODE_TOO_MANY_ATTEMPTS);
}
// 第 3 步:对比验证码值(恒时比较,防止时序侧信道攻击)
if (!constantTimeEquals(correctCode, code)) {
incrementAttempts(email);
throw new BusinessException(400, ErrorCode.EMAIL_CODE_INVALID);
}
// 第 4 步:验证通过,删除验证码(用一次就失效)
redisTemplate.delete(key);
redisTemplate.delete(ATTEMPT_KEY + email);
}
/** 获取当前错误次数 */
private long getAttemptCount(String email) {
String val = redisTemplate.opsForValue().get(ATTEMPT_KEY + email);
return val == null ? 0 : Long.parseLong(val);
}
/** 错误次数 +1,同步续期 */
private void incrementAttempts(String email) {
redisTemplate.opsForValue().increment(ATTEMPT_KEY + email);
redisTemplate.expire(ATTEMPT_KEY + email,
properties.getCodeTtlSeconds(), TimeUnit.SECONDS);
}
// ========== 私有方法 ==========
/** 生成 6 位数字验证码 */
private String generateCode() {
int value = ThreadLocalRandom.current().nextInt(0, 1_000_000);
return String.format("%06d", value);
}
/** 尝试获取冷却期令牌(原子操作) */
private boolean tryAcquireCooldown(String email) {
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(COOLDOWN_KEY + email, "1",
properties.getCooldownSeconds(), TimeUnit.SECONDS);
return Boolean.TRUE.equals(acquired);
}
/** 恒时比较,防止时序侧信道攻击 */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null || a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
/** 构建 HTML 邮件正文 */
private String buildHtmlBody(String code, int ttlSeconds) {
String spaced = String.join(" ", code.split(""));
int minutes = Math.max(1, ttlSeconds / 60);
String brand = properties.getSubjectPrefix();
return """
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',
'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
background:#f5f6f8;padding:32px 16px;">
<div style="max-width:420px;margin:0 auto;background:#ffffff;
border-radius:12px;padding:32px 28px;">
<div style="text-align:center;color:#4A90E2;font-size:18px;
font-weight:600;margin-bottom:20px;">%s</div>
<div style="font-size:15px;color:#333333;line-height:1.7;">
您好,<br>您正在进行 <strong>注册账号</strong> 操作,验证码为:
</div>
<div style="text-align:center;font-size:34px;letter-spacing:12px;
color:#4A90E2;font-weight:500;margin:26px 0;">%s</div>
<div style="font-size:13px;color:#999999;text-align:center;">
验证码 %d 分钟内有效,请勿泄露给他人。
</div>
</div>
<div style="text-align:center;font-size:12px;color:#bbbbbb;
margin-top:24px;">
此邮件由系统自动发送,请勿回复。
</div>
</div>
""".formatted(brand, spaced, minutes);
}
}
5.3 控制器
import com.example.common.Result;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/email")
@RequiredArgsConstructor
public class EmailController {
private final EmailCodeService emailCodeService;
/** 发送验证码 */
@PostMapping("/send-code")
public Result<Void> sendCode(
@RequestParam @NotBlank @Email String email) {
emailCodeService.sendCode(email);
return Result.success("验证码已发送", null);
}
/** 校验验证码(仅验证,不执行业务) */
@PostMapping("/verify")
public Result<Void> verify(
@RequestParam @NotBlank @Email String email,
@RequestParam @NotBlank String code) {
emailCodeService.verify(email, code);
return Result.success("验证成功", null);
}
}
效果示例
发送成功:
{"code":0,"message":"验证码已发送","data":null}
验证码错误:{"code":40001,"message":"验证码错误","data":null}
请求太频繁:{"code":42901,"message":"请求过于频繁,请稍后再试","data":null}
六、HTML 邮件模板
纯文本邮件太朴素,一封设计精良的验证码邮件能大幅提升产品质感。上面核心代码里已经内置了 HTML 模板,这里单独抽出来讲一下设计。
邮件 HTML 的特殊规则
邮件客户端对 CSS 的支持远不如浏览器。核心原则:全部用内联样式(style 属性),避免外部 CSS、避免 <style> 标签、避免 flex/grid 布局。用 div + inline style 覆盖 95% 场景足够。
6.1 完整模板
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',
'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
background:#f5f6f8;padding:32px 16px;">
<div style="max-width:420px;margin:0 auto;background:#ffffff;
border-radius:12px;padding:32px 28px;">
<!-- 品牌名 -->
<div style="text-align:center;color:#4A90E2;font-size:18px;
font-weight:600;margin-bottom:20px;">
ArtVerse
</div>
<!-- 问候语 -->
<div style="font-size:15px;color:#333333;line-height:1.7;">
您好,<br>
您正在进行 <strong>注册账号</strong> 操作,验证码为:
</div>
<!-- 验证码(核心视觉) -->
<div style="text-align:center;font-size:34px;letter-spacing:12px;
color:#4A90E2;font-weight:500;margin:26px 0;">
4 5 6 8 4 7
</div>
<!-- 有效期提示 -->
<div style="font-size:13px;color:#999999;text-align:center;">
验证码 5 分钟内有效,请勿泄露给他人。
</div>
</div>
<!-- 页脚 -->
<div style="text-align:center;font-size:12px;color:#bbbbbb;
margin-top:24px;">
此邮件由系统自动发送,请勿回复。
</div>
</div>
收件人效果如下:

后记
对个人开发者来说,单体方案完全够用了,哪来那么多规矩,人和代码有一个能跑就行了。等后续业务量上来、需要独立运维邮件服务时,再把发信逻辑拆成独立微服务也不迟。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_87660168/article/details/167282179



