跳至主要內容

Springboot系列之接入邮箱验证码

holic-x...大约 10 分钟框架Springboot

Springboot-邮箱验证码

构建说明

原生邮件发送实现参考文章open in new window

构建思路

  • 基于spring-boot-starter-email 工具包(或基于原生javax.mail)实现邮件发送功能
  • 借助junit-vintage-engine工具包或者freemarker模板实现html邮件模板功能
  • 利用easy-captcha工具包生成随机验证码(或者自定义工具类生成随机验证码)
  • 缓存借助guvcache或者redis缓存进行构建(对比guvcache和redis的优缺点)

构建步骤

1.准备一个邮件发送账号,开通SMTP服务
2.项目配置:引入邮箱服务所需依赖,构建功能实现
3.向目标邮箱发送随机验证码、用户输入验证码进行校验完成注册

实现说明

1.邮件服务构建(实现邮件发送)

1)SMTP/MAP服务开启

​ 以QQ邮箱为例,开启SMTP/MAP服务并配置,进行授权码管理(生成授权码,可设置备注信息管理授权码),该授权码是用作邮件发送密码

2)构建email服务

引入相关依赖(spring-boot-starter-mail邮箱服务、freemarker模板)、配置邮箱服务参数

				<!-- 邮箱配置相关依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>

        <!--引入模板引擎(动态模板构建美化页面显示) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
spring:	
	# 邮箱配置
  mail:
    # 负责发送验证码的邮箱配置
    email: # 发送者邮箱
    host: smtp.qq.com
    port: 465
    username: # 发送者邮箱
    password: qehuasinhglyddhc # 授权密码(非邮箱密码)授权码用于登录第三方邮件客户端的专用密码
    protocol: smtps
    default-encoding: UTF-8 # 默认编码格式
    properties:
      mail:
        debug: true #启动debug调试
        smtp:
          socketFactory:
            class: javax.net.ssl.SSLSocketFactory #SSL连接配置
            
# 自定义项目配置相关
custom:
  emailCode:
    expiration: 30

构建email服务

/**
 * 邮件服务接口定义
 */
public interface EmailService {

    /**
     * 发送邮件
     * @param to
     * @param subject
     * @param content
     */
    void sendMail(String to, String subject, String content);

}
/**
 * 邮箱服务实现
 */
@Service
public class EmailServiceImpl implements EmailService {

    @Resource
    private JavaMailSender mailSender;

    @Value("${spring.mail.email}")
    private String email;

    @Override
    public void sendMail(String to, String subject, String content) {
        // 读取邮箱配置
        if (email == null) {
            throw new RuntimeException("邮箱配置异常");
        }

        // 创建邮件消息
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = null;
        try {
            helper = new MimeMessageHelper(message, true);
            // 设置发件人邮箱
            helper.setFrom(email);
            // 设置收件人信息
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, true);
        } catch (MessagingException e) {
            throw new RuntimeException(e);
        }

        // 发送邮件
        mailSender.send(message);
    }
}
  • 发送邮件模板(resources/templates/email-code.ftl)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <style>
        @page {
            margin: 0;
        }
    </style>
</head>
<body>
<div class="header">
    <div style="padding: 10px;padding-bottom: 0px;">
        <p style="margin-bottom: 10px;padding-bottom: 0px;">尊敬的用户,您好:</p>
        <p style="text-indent: 2em; margin-bottom: 10px;">您正在注册【一人の境】平台账号,您的验证码为:</p>
        <p class="code-text">${code}</p>
        <p style="text-indent: 2em; margin-bottom: 10px;">请确认查收!验证码有效期为${expiration}分钟,请在有效期内完成操作</p>
        <div class="footer">
        </div>
    </div>
</div>
</body>
</html>

<style lang="css">
    body {
        margin: 0px;
        padding: 0px;
        font: 100% SimSun, Microsoft YaHei, Times New Roman, Verdana, Arial, Helvetica, sans-serif;
        color: #000;
    }

    .header {
        height: auto;
        width: 820px;
        min-width: 820px;
        margin: 0 auto;
        margin-top: 20px;
        border: 1px solid #eee;
    }

    .code-text {
        text-align: center;
        font-family: Times New Roman;
        font-size: 22px;
        color: #C60024;
        padding: 20px 0px;
        margin-bottom: 10px;
        font-weight: bold;
        background: #ebebeb;
    }

    .footer {
        margin: 0 auto;
        z-index: 111;
        width: 800px;
        margin-top: 30px;
        border-top: 1px solid #DA251D;
    }
</style>

3)构建发送验证码接口

public interface AccountService {
    /**
     * 发送邮箱验证码
     * @return
     */
    boolean sendEmailCode(String email);
}
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {

    @Resource
    private EmailService emailService;

    @Value("${custom.emailCode.expiration}")
    private Long expiration;

    @Override
    public void userLogout() {
        // ShiroUtil.deleteCache();

        // 退出登陆
        Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated()) {
            // 销毁SESSION(清理权限缓存)
            subject.logout();
        }
    }

    @Override
    public boolean sendEmailCode(String email) {
        // todo 查看注册邮箱是否存在,引入缓存机制

        // 获取发送邮箱验证码的HTML模板(resources/templates/下存放模板信息)
        TemplateEngine engine = TemplateUtil.createEngine(new TemplateConfig("templates", TemplateConfig.ResourceMode.CLASSPATH));
        Template template = engine.getTemplate("email-code.ftl");

        // 调用邮箱服务发送验证码信息
        String subject = "邮箱验证码";
        String content = template.render(Dict.create().set("code", RandomUtil.randomNumbers(6)).set("expiration", expiration));
        emailService.sendMail(email,subject,content);
        // 返回响应信息
        return true;
    }
}
@RestController
@RequestMapping("/account")
public class AccountController {

    @Resource
    private AccountService accountService;
  
      /**
     * 发送邮箱验证码
     * @return
     */
    @GetMapping("/sendEmailCode")
    public BaseResponse<Boolean> sendEmailCode(@RequestParam String email) {
        // 调用验证码服务获取邮箱验证码信息
        accountService.sendEmailCode(email);
        return ResultUtils.success(true);
    }

}

4)测试验证码发送

​ 基于上述步骤,构建验证码发送接口,通过接口调试测试验证码发送,如果发送出现问题则依次排查配置等问题(例如发送者的邮箱、授权码等)

​ 参考访问API:http://localhost:8101/api/account/sendEmailCode

image-20240513144707236

2.引入缓存(嵌入业务逻辑)

接口定义

  • 发送验证码接口:发送验证码到指定邮箱
  • 换绑邮箱接口:邮箱换绑

缓存配置

引入redis缓存

				<!-- redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        
				<!--fastjson依赖    Redis使用FastJson序列化-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.33</version>
        </dependency>

配置redis:(此处使用springboot-redis,区分BI模块redis限流引入的RedissonConfig、RedisLimiterManager)

  • application.yml:配置redis
  • RedisConfig:构建redis客户端
  • RedisCache:构建redis缓存工具类
  • FastJsonRedisSerializer:redis数据序列化器(使用fastjson进行序列化)
spring:
	# Redis 配置
  redis:
    database: 1
    host: localhost
    port: 6379
    timeout: 5000
    password: 123456
@Configuration
public class RedisConfig {

    @Value("${spring.redis.database}")
    private Integer database;

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private Integer port;

    // 如果redis没有默认密码则不用写
    @Value("${spring.redis.password}")
    private String password;

    // 显式定义redisConnectionFactory
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        // 设定redis配置(IP、端口、密码登配置)
        LettuceConnectionFactory redisConnectionFactory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
        redisConnectionFactory.setPassword(password);
        return redisConnectionFactory;
    }

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }
}
@Component
public class RedisCache {

    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }

    /**
     * 更新redis文章浏览量
     * @param key 哪一个哈希结构
     * @param hKey 哈希表里哪一个数据
     * @param v 更改值
     */
    public void incrementCacheMapValue(String key,String hKey,long v){
        redisTemplate.boundHashOps(key).increment(hKey, v);
    }
}
/**
 * Redis使用FastJson序列化
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

接口实现

controller:定义接口(发送邮箱验证码)、换绑邮箱

@RestController
@RequestMapping("/account")
public class AccountController {

    @Resource
    private AccountService accountService;
    
    /**
     * 发送邮箱验证码
     * @return
     */
    @GetMapping("/sendEmailCode")
    public BaseResponse<Boolean> sendEmailCode(@RequestParam String email) {
        // 调用验证码服务获取邮箱验证码信息
        boolean res = accountService.sendEmailCode(email);
        return ResultUtils.success(res);
    }

    /**
     * 换绑邮箱
     * @return
     */
    @GetMapping("/bindEmail")
    public BaseResponse<Boolean> bindEmail(@RequestParam String email,@RequestParam String code) {
        // 调用验证码服务获取邮箱验证码信息
        boolean res = accountService.bindEmail(email,code);
        return ResultUtils.success(res);
    }

}

service

public interface AccountService {
    /**
     * 发送邮箱验证码
     * @return
     */
    boolean sendEmailCode(String email);
  
   /**
     * 绑定邮箱
     * @return
     */
    boolean bindEmail(String email,String code);
}
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {
    @Resource
    private EmailService emailService;

    @Resource
    private RedisCache redisCache;

    @Value("${custom.emailCode.expiration}")
    private Long expiration;

    @Resource
    private UserMapper userMapper;

    @Override
    public boolean sendEmailCode(String email) {
        // 定义存储键值对的键规则
        String emailCodeKey = "emailCode:" + email;

        // 从redis缓存中尝试获取验证码
        String cacheCode = redisCache.getCacheObject(emailCodeKey);
        String emailCode = "";
        // 如果缓存中已经存在字符串数据,则直接取出
        if(StringUtils.isBlank(cacheCode)){
            // 随机生成6位验证码
            emailCode = RandomUtil.randomNumbers(6);
            // 将邮箱和验证码信息存入redis缓存
            redisCache.setCacheObject(emailCodeKey,emailCode);
            redisCache.expire(emailCodeKey,expiration*100);
        }else{
            // 缓存中已经存在数据,不重复生成,直接返回缓存数据(或者提示用户不要操作太频繁,确认邮箱后再重新尝试)
            // emailCode = cacheCode;
            throw new BusinessException(ErrorCode.TOO_MANY_REQUEST,"验证码已生成并发送到您邮箱,请确认后再次尝试");
        }

        // 获取发送邮箱验证码的HTML模板(resources/templates/下存放模板信息)
        TemplateEngine engine = TemplateUtil.createEngine(new TemplateConfig("templates", TemplateConfig.ResourceMode.CLASSPATH));
        Template template = engine.getTemplate("email-code.ftl");

        // 调用邮箱服务发送验证码信息
        String subject = "【一人の境】邮箱验证码";
        String content = template.render(Dict.create().set("code",emailCode).set("expiration", expiration));
        emailService.sendMail(email,subject,content);
        // 返回响应信息
        return true;
    }

    /**
     * 根据验证码、邮箱绑定当前用户邮箱信息
     * @param email
     * @param code
     * @return
     */
    @Override
    public boolean bindEmail(String email, String code) {
        // 校验当前指定的邮箱是否已被绑定(查看注册邮箱是否存在)
        User findUser = userMapper.getUserVOByEmail(email);
        ThrowUtils.throwIf(findUser != null,ErrorCode.USER_EMAIL_REPEAT_ERROR,"当前邮箱账号已被他人绑定,请确认后再次尝试");

        // 获取缓存中的验证码信息
        String emailCodeKey = "emailCode:" + email;
        String cacheCode = redisCache.getCacheObject(emailCodeKey);
        ThrowUtils.throwIf(StringUtils.isBlank(cacheCode),ErrorCode.VALID_CODE_ERROR,"当前验证码已过期,请稍后再次尝试");

        // 校验验证码是否正确
        ThrowUtils.throwIf(!cacheCode.equals(code),ErrorCode.VALID_CODE_ERROR,"验证码校验失败,请确认后再次尝试");

        // 邮箱账号和验证码校验通过,更新邮箱信息
        User user = new User();
        user.setId(ShiroUtil.getCurrentUserId());
        user.setUserEmail(email);
        user.setUpdateTime(new Date());
        int res = userMapper.updateById(user);
        return res>0;
    }
}

​ 基于上述步骤,完善邮箱验证码逻辑,将其嵌入业务逻辑(发送邮箱验证码、后台验证邮箱验证码并进行换绑操作)

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3