Vue+SpringBoot实现注册发送邮件激活,完整闭环设计 📅 发布时间:2026/9/1 10:44:04 👁 浏览次数: 简介本资源是一套完整的前后端分离式用户邮箱激活系统实现方案面向Web全栈初学者及SpringBootVue技术栈学习者解决注册流程中身份核验与安全激活的核心问题。压缩包共63个文件含17个JavaScript前端逻辑文件、10个Java后端服务类、5个Vue组件、2个YML配置文件及SQL建表脚本等涵盖前端表单验证、RESTful接口开发、JavaMailSender邮件发送、Thymeleaf动态模板渲染、激活码生成与时效校验等关键模块整体大小仅675KB轻量易部署。已有493人学习下载资源结构清晰前端基于Vue CLI构建后端采用SpringBoot 2.x JPA HikariCP配套SQL初始化脚本与完整目录说明可直接运行调试助读者快速掌握邮箱激活全流程的代码组织、异常处理与安全实践如密码加盐、激活链接有效期控制。 最近有朋友问我说做了一个带用户注册功能的SpringBoot项目总感觉少了点什么——用户注册完就能直接登录根本没法确认邮箱是不是真的属于这个人垃圾账号、僵尸账号一抓一大把。他说得挺对现在的正经互联网产品注册环节基本都会接一道邮箱或手机验证而邮箱激活又是其中最经典、也最适合拿来练手全栈能力的一种方案。正好我手上这套“Vue SpringBoot 注册发送邮件激活”的代码一直在用就借这篇博文把整个思路和实现细节完整拆一遍从后端接口设计到前端页面交互再到各种边角坑一次说清楚。这套东西能解决什么问题说白了就三件事一是新用户注册后往邮箱发一封带激活链接的邮件二是用户点链接之后后端校验链接的有效性三是校验通过后把账户状态从未激活翻成正常可用。它适合刚入门SpringBoot但想做一个“完整闭环”项目的人也适合前端想趁便理解一下怎么配合后端做状态流转的同学。1. 激活闭环的整体设计先把流程想明白再动手只要你写过一次带注册功能的系统就会发现“发激活邮件”这种事难的不是发信本身而是整个状态的流转怎么设计。很多人一上来就写代码一会儿在User表里加个字段一会儿在Controller里堆逻辑写着写着就乱套了。所以我建议先把整条链路画一遍把每个环节的输入输出和状态变化定死再动键盘。1.1 激活链接的本质一串带令牌的URL激活邮件里放的链接本质上就是一个普通GET请求长这样http://localhost:8080/api/user/activate?tokenxxxxxx用户点这个链接后后端拿到token去数据库里查一下有没有对应的记录校验是否过期如果一切正常就把用户状态改成“已激活”然后返回一个前端页面告诉用户“你的账户已经激活成功”。这里有个很容易忽略的点不要把用户ID直接拼在链接里。比如?userId123这种看起来简单但恶意用户完全可以遍历ID批量激活或者干扰别人的账户。正确做法是生成一个随机且不可猜测的token把token作为激活凭证用户ID藏在token背后激活时通过token反查用户。我习惯用UUID作为token虽然有人说UUID有可预测性风险但结合过期时间和一次性消费机制在注册激活这个场景下完全够用。如果你追求更严格的安全级别可以上jose4j之类的库生成JWT但恕我直言注册激活这种低频、短时效的操作UUIDRedis或数据库存储已经是大多数商业项目的常规做法了。1.2 状态机设计未激活、已激活、已过期用户表里最核心的字段就两个status和active_token。前者记录账户状态后者存激活令牌。我一般这样定义状态状态值含义说明0未激活注册成功但还没点邮件链接1已激活邮件链接已正确点击账户可用2已过期token过期或已被消费需要重新发送邮件看清楚了这里我把“已过期”也作为一个状态。为什么不直接删除记录因为用户可能还想重新收邮件保留状态能让你在重新发送邮件时判断“这个用户是第一次激活还是过期重发”后续做运营统计也方便。数据库建表语句大概是这样的CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL, password VARCHAR(100) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-未激活 1-已激活 2-已过期, active_token VARCHAR(64) DEFAULT NULL, token_expire_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, activate_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_email (email), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意token_expire_time这个字段很多人会忽略。没有它token过期逻辑就只能写在代码里时间一长就忘了。放在库里你随时能查“有多少用户发了邮件但没激活”运营同学也方便看数据。1.3 前端页面流程从注册表单到激活结果页前端的页面流转其实很简单三个页面就够了注册页用户输用户名、邮箱、密码提交后提示“去邮箱查收激活邮件”激活中/激活失败页用户点了邮件链接落在后端返回的页面或前端路由页上根据后端的返回结果展示不同文案激活成功页显示成功信息附“去登录”按钮如果你用的是Vue Router可以在路由表里配一个/activate页面后端激活接口返回成功后前端跳转到这里展示。但更简单的玩法是后端激活接口直接返回一个HTML页面省去前端路由的跨域麻烦。等下我会详细讲这两种方案的取舍。2. 后端核心实现SpringBoot发邮件与激活接口后端是这套功能的重心。我从依赖配置开始一步步把完整可跑的代码给你列出来。2.1 引入邮件依赖和基础配置SpringBoot发邮件靠的是spring-boot-starter-mail这个起步依赖它底层封装了JavaMailSender把Session、Transport这些底层的复杂操作全部隐藏掉了。你只需要在application.yml里配好邮箱SMTP信息然后注入JavaMailSender就能发信。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency配置文件这里有个关键点很多人直接在配置里写死密码然后就发不出去因为现在各大邮箱厂商都要求使用“授权码”而不是登录密码。以QQ邮箱为例你需要去设置里开启SMTP服务拿到一个授权码然后填在spring.mail.password里。spring: mail: host: smtp.qq.com port: 465 username: your-emailqq.com password: your-auth-code protocol: smtps properties: mail: smtp: auth: true ssl: enable: true我这里的配置用的是465端口SSL也就是隐式SSL连接。也有用587端口STARTTLS的写法具体看你邮箱服务商支持哪种。QQ邮箱两种都支持但465更省事因为不需要额外开STARTTLS的参数。2.2 发邮件的Service层邮件内容与模板拼接发邮件的核心逻辑放在UserService里。注册成功后做这么几件事生成token、算过期时间、落库、组装邮件内容、异步发送。Service public class UserService { Autowired private JavaMailSender mailSender; Autowired private UserMapper userMapper; Value(${app.base-url}) private String baseUrl; Value(${spring.mail.username}) private String fromEmail; public void register(User user) { // 1. 检查用户名、邮箱唯一性 // 2. 密码加密 // 3. 生成激活token和过期时间 String token UUID.randomUUID().toString().replace(-, ); // 截掉-缩短URL长度 user.setActiveToken(token); user.setTokenExpireTime(new Date(System.currentTimeMillis() 30 * 60 * 1000)); // 30分钟有效 user.setStatus(0); userMapper.insert(user); // 4. 组装激活链接 String activateUrl baseUrl /api/user/activate?token token; String content buildActivateContent(user.getUsername(), activateUrl); // 5. 发送邮件异步 sendActivateEmail(user.getEmail(), content); } }注意我这里的baseUrl是配置在application.yml里的因为本地开发、测试环境、生产环境的域名都不一样。直接写死localhost的话部署到服务器上链接就失效了那可就闹笑话了。app: base-url: http://localhost:80802.3 邮件内容模板别用纯文本HTML才是正道早期我图省事直接用纯文本拼邮件内容结果用户在邮箱里看到的内容丑得不行链接还经常被邮件客户端自动断行。后来学乖了用简单的HTML模板拼内容体验完全不一样。private String buildActivateContent(String username, String activateUrl) { return html body h3亲爱的 username 欢迎注册/h3 p请点击以下链接激活你的账户/p a href\ activateUrl \ activateUrl /a p链接30分钟内有效如果链接无法点击请复制到浏览器地址栏访问。/p p如果不是你本人操作请忽略这封邮件。/p /body /html; }为啥要用a标签而不是直接把URL放正文里因为部分邮件客户端会把文本形式的URL自动链接但展示出来的文字和实际跳转地址可能不一致用户点了之后莫名其妙404。用a标签能保证点击行为完全受控。发邮件的方法也简单用MimeMessageHelper构造HTML邮件Async public void sendActivateEmail(String toEmail, String content) { try { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, UTF-8); helper.setFrom(fromEmail); helper.setTo(toEmail); helper.setSubject(账号激活通知); helper.setText(content, true); // true表示HTML格式 mailSender.send(message); } catch (MessagingException e) { // 记录日志不要影响主流程 log.error(发送激活邮件失败, e); } }这里有个细节值得说helper.setText(content, true)的第二个参数true我见过太多人漏掉结果邮件内容以纯文本形式显示HTML源码全部暴露出来非常丑。务必检查。2.4 激活接口校验token并更新状态用户点击邮件链接后请求会打到下面这个接口上GetMapping(/api/user/activate) public String activate(RequestParam(token) String token, Model model) { User user userMapper.findByActiveToken(token); if (user null) { model.addAttribute(message, 激活链接无效); return activate-result; } if (user.getStatus() 1) { model.addAttribute(message, 该账户已激活请直接登录); return activate-result; } if (user.getTokenExpireTime().before(new Date())) { // 更新状态为已过期 userMapper.updateStatus(user.getId(), 2); model.addAttribute(message, 激活链接已过期请重新发送激活邮件); return activate-result; } // 激活成功 userMapper.updateStatus(user.getId(), 1); userMapper.updateActivateTime(user.getId(), new Date()); model.addAttribute(message, 激活成功请登录); return activate-result; }这个接口的校验顺序是精心设计过的先查token是否存在再看是否已激活最后看是否过期。这三个判断顺序不能乱。如果把过期判断放在已激活判断前面那已经激活的用户再次点击链接时如果token过期了就会显示“已过期”明显不合理。关于返回值类型我这里用的是返回HTML页面activate-result.html而不是返回JSON。原因很简单用户是在浏览器里点链接直接返回一个页面比返回JSON后再让前端搞二次跳转要省事得多。这个模板就放在SpringBoot的resources/templates目录下用Thymeleaf渲染。2.5 补充一个主动查询接口登录前的状态检查激活完成后用户大概率会去登录。登录接口里也要做一次状态判断如果status为0提示“请先激活账户”如果status为2提示“激活链接已过期请重新发送邮件”。PostMapping(/api/user/login) public Result login(RequestBody LoginRequest request) { User user userMapper.findByUsername(request.getUsername()); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } if (user.getStatus() 0) { return Result.error(账户未激活请查收激活邮件); } if (user.getStatus() 2) { return Result.error(激活链接已过期请重新发送激活邮件); } return Result.success(); }这一步很多人会漏掉导致用户注册了不激活也能登录那前面的激活就白做了。你一定要在登录入口加这道检查。3. 前端Vue实现注册表单与状态提示交互接下来说Vue这边。整体架构就是Vue3 Vite Vue Router Axios再配合Element Plus做UI。这个组合目前是Vue生态最主流的社区资料也最全碰到问题基本都能搜到答案。3.1 环境准备Node和Vite脚手架用Vite创建Vue3项目是目前最快的路径比Webpack配置省太多事了。# 创建项目 npm create vitelatest user-register-frontend -- --template vue # 进入项目并安装依赖 cd user-register-frontend npm install # 安装路由、Axios和UI组件库 npm install vue-router4 axios element-plus启动开发服务器npm run dev默认端口是5173但这里有一个非常关键的配置要处理跨域问题。前端跑在5173后端跑在8080端口不同意味着跨域不解决的话请求根本发不出去。我习惯用Vite的代理配置来解决而不是在后端写CrossOrigin因为代理方式更灵活前后端各自都保持独立生产环境切换也方便。在vite.config.js里加import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置完成后前端请求/api/user/register就会自动转发到http://localhost:8080/api/user/register前端代码里不需要写完整的后端地址。3.2 注册页面表单校验与提交注册页长这样三个输入框用户名、邮箱、密码一个注册按钮。用Element Plus的el-form加校验规则用户体验比较完整。template div classregister-container el-card classregister-card h2用户注册/h2 el-form refregisterFormRef :modelregisterForm :rulesregisterRules label-width80px el-form-item label用户名 propusername el-input v-modelregisterForm.username placeholder请输入用户名 / /el-form-item el-form-item label邮箱 propemail el-input v-modelregisterForm.email placeholder请输入邮箱 / /el-form-item el-form-item label密码 proppassword el-input v-modelregisterForm.password typepassword show-password placeholder请输入密码 / /el-form-item el-form-item el-button typeprimary :loadingsubmitting clickhandleRegister 注册 /el-button /el-form-item /el-form /el-card /div /template注册按钮的提交逻辑也比较直接const handleRegister async () { await registerFormRef.value.validate() submitting.value true try { const res await axios.post(/api/user/register, { username: registerForm.value.username, email: registerForm.value.email, password: registerForm.value.password }) if (res.data.code 200) { ElMessage.success(注册成功请查收激活邮件) router.push({ name: registerSuccess, query: { email: registerForm.value.email } }) } else { ElMessage.error(res.data.msg) } } finally { submitting.value false } }有个小细节注册成功后我跳转到了一个“注册成功提示页”专门告诉用户“邮件已发送请去XX邮箱查收”比停留在原表单页什么都不说体验好得多。3.3 注册成功页与激活结果页注册成功页很简单就是一段提示文案加一个去登录按钮。激活结果页也不复杂。这里的核心问题在于激活链接点击后到底落到哪里我的答案是别用前端路由。原因我在前面说过SpringBoot后端返回HTML页面是最省事的方案。但如果你非要走前端路由也有一个可行的办法——在Vue路由里配上/activate页面然后激活接口返回一个跳转URL// 后端逻辑改成这样 return redirect:http://localhost:5173/activate?statussuccessmessage激活成功这样用户点击链接会先经过SpringBoot的激活接口处理完后redirect到前端页面前端页面根据URL参数展示不同结果。好处是页面风格可以跟Vue项目完全统一坏处是前后端耦合度变高而且部署到生产环境时localhost的地址要改成真实域名。我个人实际项目里两种方案都用过最终更推荐后端直接返回模板页。不是说前端路由方案不好而是激活链接点在邮件里用户是在非受控环境下点击的你无法保证他一定开着你的前端应用。如果前端应用没启动链接就会跳到404。而后端返回页面则完全不存在这个问题只要后端服务还活着页面就能展示。3.4 Axios封装与拦截器统一处理状态码如果你的项目里还有登录、其他接口请求建议在src/utils/request.js里先封装一个Axios实例统一配置baseURL、超时时间、请求拦截器和响应拦截器。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res }, error { ElMessage.error(error.message || 请求失败) return Promise.reject(error) } ) export default request封装完以后组件里就不需要axios.post这么直白的调用了统一用request.post代码会清爽不少。4. 这条链路里我踩过的坑从垃圾邮件到重复点击写这套功能的时候我前前后后踩了不少坑。有些坑是查文档就能解决的有些是真的要在生产环境跑一阵子才会暴露出来的。这里挑几个典型的展开说说希望能帮你少走弯路。4.1 邮件进垃圾箱SPF和DKIM的设置问题做邮箱激活最尴尬的不是邮件发不出去而是发出去了用户收不到最后在垃圾箱里翻到了。我有一次测试用QQ邮箱给163邮箱发激活邮件五封里有三封进了垃圾箱一度以为是内容模板写得太像营销邮件后来才发现根因是域名没有配置SPF和DKIM记录。SPFSender Policy Framework和DKIMDomain Keys Identified Mail是两种邮件认证机制作用是让接收方邮件服务器确认“这封邮件确实是由该域名授权的服务器发送的”。如果你用的是免费邮箱QQ、163、Gmail等这些记录邮箱服务商已经帮你配好了一般不担心。但如果你用的是自己的域名邮箱就一定要去域名服务商那里把SPF记录加上。以阿里云域名为例添加一条这样的TXT记录记录类型TXT 主机记录 记录值vspf1 include:spf.your-email-provider.com ~all配置完之后等DNS生效一般几分钟到几小时不等再发一封测试邮件看看进垃圾箱的概率会大幅下降。另外还有一个细节邮件标题和正文里不要出现“点击领取”“注册即送”“万元红包”这类敏感词非常容易触发垃圾邮件过滤器。老老实实写“账号激活通知”就好不要玩花活。4.2 重复点击激活链接幂等性设计用户收到邮件可能点了两次链接也可能在另一个设备上点了一次回到原来的设备又点了一次。如果后端没有做幂等处理第二次点击就会报错或者状态错乱。我在activate接口里的处理方案是查到用户后先判断status 1如果已经激活了直接返回“该账户已激活”的提示页面不再执行更新操作。这样既保证了重复点击不报错用户体验也友好。还有一种更隐蔽的情况用户点了过期链接后系统把状态改成了“已过期”但用户间隔几秒又点了一次。第二次点击时status已经变成2会走“激活链接已过期”的分支返回提示信息不会再次更新数据库。这样整个状态流转是安全的。4.3 异步发送邮件别让发信卡住注册流程我最初写的版本是同步发邮件结果测试时发现注册接口响应时间在2-5秒之间非常折磨。后来改成异步发送注册接口秒开体验瞬间提升。异步方案很简单在SpringBoot的启动类上加上EnableAsync然后在发送邮件的方法上加Async注解。这样调用方不需要等邮件真正发出去就能立即返回。但异步有个副作用如果邮件发送失败调用方是感知不到的。所以一定要在异步方法里做好异常捕获和日志记录不然用户注册失败却收不到邮件排查起来非常痛苦。Async public void sendActivateEmail(String toEmail, String content) { try { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, UTF-8); helper.setFrom(fromEmail); helper.setTo(toEmail); helper.setSubject(账号激活通知); helper.setText(content, true); mailSender.send(message); log.info(激活邮件已发送至{}, toEmail); } catch (MessagingException e) { log.error(发送激活邮件失败收件人{}原因{}, toEmail, e.getMessage()); } }4.4 激活链接过期时间怎么定才合理我给的默认值是30分钟但这不是拍脑袋定的。太短用户可能还没看到邮件就过期了太长token被恶意利用的风险会增高。30分钟是业界比较常见的折中值既给了用户足够的操作时间又不至于让token长时间暴露在外。如果你想更灵活可以把过期时间做成配置项app: activate-token-expire-minutes: 30然后注册逻辑里从配置读取Value(${app.activate-token-expire-minutes}) private int expireMinutes; // 生成token时 Date expireTime new Date(System.currentTimeMillis() expireMinutes * 60 * 1000);这样后续如果产品想调成24小时改一行配置就行不用动代码。5. 扩展优化重新发送邮件与定时清理过期数据基础的激活功能跑通了但离“能上线”还差两个小功能一个是“重新发送激活邮件”另一个是“清理过期未激活的数据”。这两个功能做不做直接决定你这个系统在真实场景下能不能扛住用户的自然行为。5.1 重新发送激活邮件用户没收到怎么办用户注册后没收到邮件原因可能有很多填错了邮箱、邮件进了垃圾箱、网络延迟。这时候用户大概率会回到注册页面想重新获取一封邮件。你得给这个入口。实现起来不复杂就是一个新的接口接收邮箱参数查库发现用户存在且状态为未激活或已过期就重新生成token并再次发送邮件PostMapping(/api/user/resend) public Result resendActivateEmail(RequestBody ResendRequest request) { User user userMapper.findByEmail(request.getEmail()); if (user null) { return Result.error(该邮箱未注册); } if (user.getStatus() 1) { return Result.error(该账户已激活无需重新发送); } // 生成新token String newToken UUID.randomUUID().toString().replace(-, ); Date newExpireTime new Date(System.currentTimeMillis() expireMinutes * 60 * 1000); userMapper.updateToken(user.getId(), newToken, newExpireTime); // 重新发送邮件 String activateUrl baseUrl /api/user/activate?token newToken; String content buildActivateContent(user.getUsername(), activateUrl); sendActivateEmail(user.getEmail(), content); return Result.success(激活邮件已重新发送); }注意一个安全细节重新发送邮件不要校验密码因为用户可能根本没来得及设置密码或者忘了密码。只要邮箱匹配且账户未激活就可以重发。这确实会产生一个风险——如果邮箱不是本人拥有攻击者可以反复发邮件骚扰。但注册激活这种场景下这个风险是可以接受的毕竟邮件列表泄露本来就不应该靠激活功能来防护。前端页面在注册成功页上加一个“没收到邮件点击重新发送”的按钮调这个接口就完整了。5.2 定时清理防止用户表被脏数据塞满注册了但永远不激活的账户会一直躺在数据库里占用资源和索引空间。时间长了垃圾数据越来越多查询性能也会受影响。所以最好加一个定时任务把超过一定时间比如24小时仍未激活且token已过期的记录清理掉或者软删把status标记为更特殊的“作废”状态。用SpringBoot自带的Scheduled注解就能实现Component public class UserCleanupTask { Autowired private UserMapper userMapper; // 每天凌晨3点执行一次 Scheduled(cron 0 0 3 * * ?) public void cleanExpiredUnactivatedUsers() { Date cutoff new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000); int count userMapper.deleteUnactivatedBefore(cutoff); log.info(已清理未激活用户{} 个, count); } }对应的Mapper方法Delete(DELETE FROM user WHERE status 0 AND create_time #{cutoff}) int deleteUnactivatedBefore(Date cutoff);同样别忘了在启动类上加上EnableScheduling注解否则定时任务不会生效。5.3 从“能用”到“好用”邮件模板升级与日志追踪如果你的项目要做到产品级还有几个优化方向一是邮件模板工程化。刚才我用的是Java里拼HTML字符串的土办法简单是简单但后期维护很要命稍微动一下样式就得改Java代码重新部署。更好的方案是用Thymeleaf模板引擎把邮件HTML模板独立放到resources/templates/mail/activate.html里然后用Thymeleaf的TemplateEngine渲染模板Autowired private TemplateEngine templateEngine; public void sendActivateEmail(String toEmail, String username, String activateUrl) { Context context new Context(); context.setVariable(username, username); context.setVariable(activateUrl, activateUrl); String content templateEngine.process(mail/activate, context); // 发送邮件... }这样前端同学可以直接改HTML模板不碰Java代码沟通成本和维护成本都会明显降低。二是追踪邮件的发送状态。如果用户量上来了你得知道每一封邮件到底有没有成功送达。可以引入消息队列比如RabbitMQ或RocketMQ把发送邮件的请求丢进队列消费者异步处理并记录最终结果是成功还是失败。这样一来即使某一次邮件服务商临时抽风也能重试而不影响主流程。不过对于中小项目来说这个方案有点重Spring自带的Async已经能解决80%的问题。6. 调试与部署阶段最容易碰到的五个实际问题最后这节我专门盘点一下调试和部署阶段最常见的问题每个都是我实际踩过或者帮别人排查过的。前端后端各占一半一定要仔细看。6.1 端口被占用后端起不来SpringBoot默认8080端口起服务的时候经常碰到端口被别的程序占用报错信息是Port 8080 was already in use。排查方式Linux/Maclsof -i :8080查看谁占了端口Windowsnetstat -ano | findstr :8080拿到PID后在任务管理器里结束进程如果你只是临时换端口可以在启动命令里指定java -jar user-register.jar --server.port9090注意换端口后前端代理配置也要跟着改不然又会出现跨域问题。6.2 邮件发送报错Unknown SMTP host这个错误一般是配置没生效或者依赖没引入完整。最常见的原因是spring.mail.host写错了比如host后面带了协议前缀http://会直接被JavaMailSender拒绝。正确的host只需要写smtp.qq.com这样不要带协议。其他常见问题username是完整的邮箱地址不能只写前缀password是授权码不是QQ密码如果用了网易邮箱host应该写smtp.163.com6.3 前端请求404别忽略Vite代理的changeOrigin配置我在帮朋友排查一个前端404问题时发现他的Vite代理配置里漏了changeOrigin: true。这个参数的作用是修改请求头中的Host字段为目标服务器的地址。如果不加后端收到请求后Host还是localhost:5173如果后端配置了域名白名单或者虚拟主机就会报错。一般加上是对的除非你有特殊需求要保留原始Host。6.4 激活链接有效期难调解URL里不要带中文有人在邮件模板里用中文拼接链接参数比如?message激活成功然后发现浏览器自动编码后部分邮件客户端解析不了导致链接断裂。最稳妥的办法是URL参数里只放纯英文字符token所有需要展示的提示信息都由后端查询后动态生成不要拼在URL里。6.5 部署到服务器后邮件链接变成内网地址这个问题我帮两个朋友排过根源是app.base-url没有随着环境切换。本地开发填的是http://localhost:8080部署到服务器后忘了改成http://your-server-domain结果发给用户的激活链接全是localhost用户点了当然访问的是一个不存在的服务。建议把app.base-url做成分环境配置application-dev.yml里填http://localhost:8080application-prod.yml里填http://your-domain.com启动时通过spring.profiles.activeprod指定环境这样永远不会配错。写到这里这套“Vue SpringBoot注册发送邮件激活”的核心实现和周边坑位基本都讲完了。我个人的体会是这个功能看着不大但它把前端交互、后端接口设计、状态机管理、异步任务、甚至部署运维的不少知识都串起来了非常适合作为全栈入门的第二个完整项目第一个通常是增删改查。你在自己实现的时候如果能把token的设计理由、状态流转的边界条件、异步发送的取舍都讲清楚面试时这道题基本就是送分题。后面有空的话我再写一篇如何用Redis重构token存储的进阶方案关注这个方向的话可以留意后续更新。本文还有配套的精品资源点击获取