silver_kite头像
关注

个人开发者邮箱验证码完整实现

摘要:本文讲解如何用 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 / 587SSL / STARTTLS
POP3收取邮件(下载后删除)110 / 995SSL
IMAP收取邮件(双向同步)143 / 993SSL

授权码 ≠ 邮箱密码

个人邮箱的 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 &lt;T&gt; Result&lt;T&gt; success() {
    return new Result&lt;&gt;(0, "操作成功", null);
}

public static &lt;T&gt; Result&lt;T&gt; success(T data) {
    return new Result&lt;&gt;(0, "操作成功", data);
}

public static &lt;T&gt; Result&lt;T&gt; success(String message, T data) {
    return new Result&lt;&gt;(0, message, data);
}

// ========== 失败响应 ==========

public static &lt;T&gt; Result&lt;T&gt; fail(int code, String message) {
    return new Result&lt;&gt;(code, message, null);
}

public static &lt;T&gt; Result&lt;T&gt; fail(ErrorCode errorCode) {
    return new Result&lt;&gt;(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&lt;Result&lt;Void&gt;&gt; handleBusiness(BusinessException e) {
    return ResponseEntity.status(e.getStatus())
            .body(Result.fail(e.getCode(), e.getMessage()));
}

/** 参数校验异常 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity&lt;Result&lt;Void&gt;&gt; handleValidation(
        MethodArgumentNotValidException e) {
    String message = e.getBindingResult().getFieldErrors().stream()
            .map(err -&gt; err.getField() + ": " + err.getDefaultMessage())
            .findFirst()
            .orElse("参数校验失败");
    return ResponseEntity.badRequest()
            .body(Result.fail(40000, message));
}

/** 兜底异常 */
@ExceptionHandler(Exception.class)
public ResponseEntity&lt;Result&lt;Void&gt;&gt; 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>
&lt;!-- 邮件发送 --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-mail&lt;/artifactId&gt;
&lt;/dependency&gt;

&lt;!-- Redis --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-data-redis&lt;/artifactId&gt;
&lt;/dependency&gt;

&lt;!-- 参数校验 --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-validation&lt;/artifactId&gt;
&lt;/dependency&gt;

&lt;!-- Lombok(可选,省掉 getter/setter 样板代码) --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.projectlombok&lt;/groupId&gt;
    &lt;artifactId&gt;lombok&lt;/artifactId&gt;
    &lt;optional&gt;true&lt;/optional&gt;
&lt;/dependency&gt;
</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 &amp;&amp; !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 &gt;= 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 &lt; 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 """
        &lt;div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',
          'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
          background:#f5f6f8;padding:32px 16px;"&gt;
          &lt;div style="max-width:420px;margin:0 auto;background:#ffffff;
            border-radius:12px;padding:32px 28px;"&gt;
            &lt;div style="text-align:center;color:#4A90E2;font-size:18px;
              font-weight:600;margin-bottom:20px;"&gt;%s&lt;/div&gt;
            &lt;div style="font-size:15px;color:#333333;line-height:1.7;"&gt;
              您好,&lt;br&gt;您正在进行 &lt;strong&gt;注册账号&lt;/strong&gt; 操作,验证码为:
            &lt;/div&gt;
            &lt;div style="text-align:center;font-size:34px;letter-spacing:12px;
              color:#4A90E2;font-weight:500;margin:26px 0;"&gt;%s&lt;/div&gt;
            &lt;div style="font-size:13px;color:#999999;text-align:center;"&gt;
              验证码 %d 分钟内有效,请勿泄露给他人。
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;div style="text-align:center;font-size:12px;color:#bbbbbb;
            margin-top:24px;"&gt;
            此邮件由系统自动发送,请勿回复。
          &lt;/div&gt;
        &lt;/div&gt;
        """.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&lt;Void&gt; sendCode(
        @RequestParam @NotBlank @Email String email) {
    emailCodeService.sendCode(email);
    return Result.success("验证码已发送", null);
}

/** 校验验证码(仅验证,不执行业务) */
@PostMapping("/verify")
public Result&lt;Void&gt; 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;">
&lt;!-- 品牌名 --&gt;
&lt;div style="text-align:center;color:#4A90E2;font-size:18px;
  font-weight:600;margin-bottom:20px;"&gt;
  ArtVerse
&lt;/div&gt;

&lt;!-- 问候语 --&gt;
&lt;div style="font-size:15px;color:#333333;line-height:1.7;"&gt;
  您好,&lt;br&gt;
  您正在进行 &lt;strong&gt;注册账号&lt;/strong&gt; 操作,验证码为:
&lt;/div&gt;

&lt;!-- 验证码(核心视觉) --&gt;
&lt;div style="text-align:center;font-size:34px;letter-spacing:12px;
  color:#4A90E2;font-weight:500;margin:26px 0;"&gt;
  4 5 6 8 4 7
&lt;/div&gt;

&lt;!-- 有效期提示 --&gt;
&lt;div style="font-size:13px;color:#999999;text-align:center;"&gt;
  验证码 5 分钟内有效,请勿泄露给他人。
&lt;/div&gt;
</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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--