feat(user): 新增邮箱注册与验证码发送功能

- 新增 UserRegisterDTO 及 /user/register 接口
- 集成 MailerSend,异步发送 6 位验证码邮件
- 添加 RedisUtil 缓存验证码 10 分钟
- 补充 SEND_MAIL_FAILED、CONFIRM_PASSWORD_NOT_MATCH 错误码
- 关闭 Spring Security CSRF 与表单登录,放行 /user/register
- AppleService 移除 @AllArgsConstructor,改用 @Resource 注入
This commit is contained in:
2025-12-03 21:48:27 +08:00
parent ba601d329c
commit a7273e4620
15 changed files with 1590 additions and 7 deletions

View File

@@ -226,6 +226,13 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- mailerSender 邮件服务 -->
<dependency>
<groupId>com.mailersend</groupId>
<artifactId>java-sdk</artifactId>
<version>1.4.1</version>
</dependency>
<!-- 添加Hibernate Validator作为Bean Validation提供程序 -->
<dependency>
<groupId>org.hibernate.validator</groupId>

View File

@@ -30,7 +30,9 @@ public enum ErrorCode {
FILE_NAME_ERROR(40002, "文件名错误"),
USER_NOT_FOUND(40401, "用户不存在"),
USER_INFO_UPDATE_FAILED(50002, "用户信息更新失败"),
PASSWORD_OR_MAIL_ERROR(50003,"密码或邮箱错误" );
PASSWORD_OR_MAIL_ERROR(50003,"密码或邮箱错误" ),
SEND_MAIL_FAILED(50004,"邮件发送失败" ),
CONFIRM_PASSWORD_NOT_MATCH(50005,"重复密码不匹配" );
/**
* 状态码
*/

View File

@@ -0,0 +1,10 @@
package com.yolo.keyborad.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}

View File

@@ -0,0 +1,24 @@
package com.yolo.keyborad.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class MailTaskExecutorConfig {
@Bean(name = "mailTaskExecutor")
public Executor mailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("mail-sender-");
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,40 @@
package com.yolo.keyborad.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
*
* @author ziin
*/
@Configuration
public class RedisConfig {
/**
* 配置StringRedisTemplate
* @param connectionFactory Redis连接工厂
* @return StringRedisTemplate实例
*/
@Bean("redisTemplate")
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
// 设置key序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 设置value序列化方式
template.setValueSerializer(new StringRedisSerializer());
// 设置hash key序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
// 设置hash value序列化方式
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}

View File

@@ -74,7 +74,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/user/login",
"/character/listByUser",
"/user/updateInfo",
"/user/detail"
"/user/detail",
"/user/register"
};
}
@Bean

View File

@@ -2,8 +2,11 @@ package com.yolo.keyborad.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@@ -13,4 +16,21 @@ public class SecurityConfig {
// strengthcost默认 10够用了可以视情况调高比如 12
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// REST 接口通常会关掉 csrf前后端分离的话
.csrf(AbstractHttpConfigurer::disable)
// 配置请求权限
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll() // 所有请求都放行
)
// 关掉默认的登录页表单
.formLogin(AbstractHttpConfigurer::disable)
// 关掉 httpBasic 弹窗登录
.httpBasic(AbstractHttpConfigurer::disable);
return http.build();
}
}

View File

@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.AppleLoginReq;
import com.yolo.keyborad.model.dto.user.KeyboardUserReq;
import com.yolo.keyborad.model.dto.user.UserLoginDTO;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
@@ -78,4 +79,11 @@ public class UserController {
KeyboardUser keyboardUser = userService.getById(loginId);
return ResultUtils.success(BeanUtil.copyProperties(keyboardUser, KeyboardUserInfoRespVO.class));
}
@PostMapping("/register")
@Operation(summary = "用户注册",description = "用户注册接口")
public BaseResponse<Boolean> register(@RequestBody UserRegisterDTO userRegisterDTO) {
userService.userRegister(userRegisterDTO);
return ResultUtils.success(true);
}
}

View File

@@ -0,0 +1,17 @@
package com.yolo.keyborad.model.dto.user;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/3 20:56
*/
@Data
public class UserRegisterDTO {
private String mailAddress;
private String password;
private String passwordConfirm;
}

View File

@@ -3,6 +3,7 @@ package com.yolo.keyborad.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.dto.user.KeyboardUserReq;
import com.yolo.keyborad.model.dto.user.UserLoginDTO;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
@@ -19,4 +20,7 @@ public interface UserService extends IService<KeyboardUser> {
KeyboardUserRespVO login(UserLoginDTO userLoginDTO);
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
Boolean userRegister(UserRegisterDTO userRegisterDTO);
}

View File

@@ -34,7 +34,6 @@ import java.util.Objects;
*/
@Service
@Slf4j
@AllArgsConstructor
public class AppleServiceImpl implements IAppleService {
@Resource

View File

@@ -12,13 +12,21 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.KeyboardUserReq;
import com.yolo.keyborad.model.dto.user.UserLoginDTO;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.UserService;
import com.yolo.keyborad.utils.RedisUtil;
import com.yolo.keyborad.utils.SendMailUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/*
* @author: ziin
@@ -32,7 +40,14 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
private KeyboardUserMapper keyboardUserMapper;
@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;
private PasswordEncoder passwordEncoder;
@Resource
private SendMailUtils sendMailUtils;
@Resource
private RedisUtil redisUtil;
@Override
public KeyboardUser selectUserWithSubjectId(String sub) {
@@ -47,7 +62,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User" + RandomUtil.randomString(6));
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUserMapper.insert(keyboardUser);
return keyboardUser;
}
@@ -63,7 +78,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
if (keyboardUser == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (!bCryptPasswordEncoder.matches(userLoginDTO.getPassword(), keyboardUser.getPassword())) {
if (!passwordEncoder.matches(userLoginDTO.getPassword(), keyboardUser.getPassword())) {
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
}
StpUtil.login(keyboardUser.getId());
@@ -89,4 +104,22 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
}
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean userRegister(UserRegisterDTO userRegisterDTO) {
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getPasswordConfirm())) {
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
}
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setPassword(passwordEncoder.encode(userRegisterDTO.getPassword()));
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
log.info(keyboardUser.toString());
int code = RandomUtil.randomInt(100000, 999999);
redisUtil.setEx("user:"+userRegisterDTO.getMailAddress(), String.valueOf(code),600, TimeUnit.SECONDS);
sendMailUtils.sendEmail(keyboardUser.getNickName(),userRegisterDTO.getMailAddress(),code);
return keyboardUserMapper.insert(keyboardUser) > 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
package com.yolo.keyborad.utils;
import com.mailersend.sdk.MailerSend;
import com.mailersend.sdk.MailerSendResponse;
import com.mailersend.sdk.Recipient;
import com.mailersend.sdk.emails.Email;
import com.mailersend.sdk.exceptions.MailerSendException;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
/*
* @author: ziin
* @date: 2025/12/3 20:43
*/
@Component
@Slf4j
public class SendMailUtils {
@Value("${mail_access_token}")
private String accessToken;
@Async("mailTaskExecutor")
public void sendEmail(String userName,String userMail,Integer code ) {
Email email = new Email();
email.setFrom("verify code", "MS_JqR6MO@no-replay.loveamorkey.com");
Recipient recipient = new Recipient(userName, userMail);
email.addRecipient(userName,userMail);
email.setSubject("no-replay");
email.setTemplateId("k68zxl28q79lj905");
email.addPersonalization(recipient, "code", code);
MailerSend ms = new MailerSend();
ms.setToken(accessToken);
try {
MailerSendResponse response = ms.emails().send(email);
log.info("邮件发送成功 messageId={}", response.messageId);
} catch (MailerSendException e) {
log.error("邮件发送失败: {}", e.getMessage());
// 异步任务不能再抛业务异常,避免影响主业务流程
// 如果需要,你可以把失败记录写入 DB 或重试队列
}
}
}

View File

@@ -48,4 +48,6 @@ mybatis-plus:
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
appid: loveKeyboard
appsecret: kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H
appsecret: kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H
mail_access_token: mlsn.f2aafcaf4bccf01529f8636fa13a2c16c33a934d5e14be3adb0cc21c2fe40fe1