feat: 实现邮箱验证码和邮箱绑定功能

- 添加邮件发送服务实现(EmailService/EmailServiceImpl)
- 新增发送验证码、绑定邮箱、解绑邮箱接口
- 用户实体新增邮箱相关字段(userEmail/userEmailVerified)
- 添加邮件配置和JavaMailSender Bean
- 放行邮箱验证码接口(/v1/user/email/send-code)
- 新增ContextHolderUtils工具类用于获取当前用户上下文
- 完善Swagger文档注解

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-09 23:53:19 +08:00
parent fc72acf490
commit 47a468096d
15 changed files with 485 additions and 71 deletions

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CoolRequestCommonStatePersistent">
<option name="searchCache" value="UserRegisterRequestDTO" />
<option name="searchCache" value="JwtAuthenticationFilter" />
</component>
</project>

View File

@@ -27,7 +27,7 @@ public class SecurityConfiguration {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/**", "/oauth2/**", "/.well-known/**", "/doc.html", "/swagger-ui/**",
"/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/v3/api-docs", "/favicon.ico")
"/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/v3/api-docs", "/favicon.ico","/v1/user/email/send-code")
.permitAll()
.anyRequest()
.authenticated())

View File

@@ -67,5 +67,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<!-- Spring Security (optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,87 @@
package cn.meowrain.aioj.backend.framework.core.utils;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Security Context 工具类
* 用于获取当前登录用户信息
*/
@Slf4j
@UtilityClass
public class ContextHolderUtils {
/**
* 获取当前登录用户ID
* @return 用户ID
* @throws IllegalStateException 如果用户未登录
*/
public static Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new IllegalStateException("用户未登录");
}
String userId = authentication.getName();
try {
return Long.valueOf(userId);
}
catch (NumberFormatException e) {
log.error("解析用户ID失败: {}", userId, e);
throw new IllegalStateException("无效的用户ID");
}
}
/**
* 获取当前登录用户ID可选返回
* @return 用户ID未登录返回 null
*/
public static Long getCurrentUserIdOrNull() {
try {
return getCurrentUserId();
}
catch (Exception e) {
return null;
}
}
/**
* 获取当前登录用户角色
* @return 用户角色,如 "USER", "ADMIN", "BAN"
*/
public static String getCurrentUserRole() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new IllegalStateException("用户未登录");
}
return authentication.getAuthorities()
.stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse("USER");
}
/**
* 判断当前用户是否已登录
* @return true-已登录, false-未登录
*/
public static boolean isAuthenticated() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null && authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getName());
}
/**
* 判断当前用户是否为管理员
* @return true-是管理员, false-不是管理员
*/
public static boolean isAdmin() {
return "ADMIN".equals(getCurrentUserRole());
}
}

View File

@@ -18,6 +18,11 @@
</properties>
<dependencies>
<!--引入spring boot email-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- SpringDoc OpenAPI - 显式指定版本以兼容 Spring Boot 3.5.x -->
<dependency>
<groupId>org.springdoc</groupId>

View File

@@ -1,8 +1,44 @@
package cn.meowrain.aioj.backend.userservice.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
@Configuration
public class FrameworkConfiguration {
@Value("${spring.mail.host}")
private String mailHost;
@Value("${spring.mail.port}")
private Integer mailPort;
@Value("${spring.mail.username}")
private String mailUsername;
@Value("${spring.mail.password}")
private String mailPassword;
@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailHost);
mailSender.setPort(mailPort);
mailSender.setUsername(mailUsername);
mailSender.setPassword(mailPassword);
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.debug", "false");
return mailSender;
}
}

View File

@@ -2,35 +2,76 @@ package cn.meowrain.aioj.backend.userservice.controller;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import cn.meowrain.aioj.backend.framework.core.web.Results;
import cn.meowrain.aioj.backend.userservice.dto.req.EmailSendCodeRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.userservice.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController()
@RequestMapping("/v1/user")
@Tag(name = "用户管理", description = "用户注册、查询等接口")
public class UserController {
private final UserService userService;
private final UserService userService;
@PostMapping("/register")
public Result<Long> register(@RequestBody UserRegisterRequestDTO userRegisterRequest) {
Long l = userService.userRegister(userRegisterRequest);
return Results.success(l);
}
@GetMapping("/inner/get-by-username")
public Result<UserAuthRespDTO> getUserByUserName(@RequestParam("userAccount") String userAccount) {
UserAuthRespDTO userAuthDTO = userService.findAuthInfoByUserAccount(userAccount);
return Results.success(userAuthDTO);
}
@PostMapping("/register")
@Operation(summary = "用户注册", description = "根据用户注册信息创建新用户")
public Result<Long> register(
@Parameter(description = "用户注册信息", required = true)
@RequestBody UserRegisterRequestDTO userRegisterRequest) {
Long l = userService.userRegister(userRegisterRequest);
return Results.success(l);
}
@GetMapping("/inner/get-by-userid")
public Result<UserAuthRespDTO> getUserById(@RequestParam("userId") String userid) {
UserAuthRespDTO userAuthRespDTO = userService.findAuthInfoByUserId(userid);
return Results.success(userAuthRespDTO);
}
@GetMapping("/inner/get-by-username")
@Operation(summary = "根据用户名查询用户", description = "通过用户账号获取用户认证信息(内部接口)")
public Result<UserAuthRespDTO> getUserByUserName(
@Parameter(description = "用户账号", required = true, example = "admin")
@RequestParam("userAccount") String userAccount) {
UserAuthRespDTO userAuthDTO = userService.findAuthInfoByUserAccount(userAccount);
return Results.success(userAuthDTO);
}
@GetMapping("/inner/get-by-userid")
@Operation(summary = "根据用户ID查询用户", description = "通过用户ID获取用户认证信息(内部接口)")
public Result<UserAuthRespDTO> getUserById(
@Parameter(description = "用户ID", required = true, example = "1")
@RequestParam("userId") String userid) {
UserAuthRespDTO userAuthRespDTO = userService.findAuthInfoByUserId(userid);
return Results.success(userAuthRespDTO);
}
/* ================================邮箱相关接口 ======================*/
/**
* 发送验证码
*/
@GetMapping("/email/send-code")
public Result<Void> getVerifyCode(@Parameter(description = "邮箱信息", required = true)
@Valid @RequestParam EmailSendCodeRequestDTO request) {
userService.sendEmailCode(request.getEmail());
return Results.success(null);
}
/**
* 绑定邮箱
*/
@PostMapping("/email/bind")
public Result<Void> bindEmail(@RequestBody String email) {
// TODO: 待实现
return null;
}
@PostMapping("/email/unbind")
public Result<Void> unbindEmail() {
// TODO: 待实现
return null;
}
}

View File

@@ -12,70 +12,80 @@ import java.util.Date;
@Accessors(chain = true)
public class User implements Serializable {
/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
/**
* 用户密码
*/
private String userPassword;
/**
* 开放平台id
*/
private String unionId;
/**
* 开放平台id
*/
private String unionId;
/**
* 公众号openId
*/
private String mpOpenId;
/**
* 公众号openId
*/
private String mpOpenId;
/**
* 用户昵称
*/
private String userName;
/**
* 用户昵称
*/
private String userName;
/**
* 用户头像
*/
private String userAvatar;
/**
* 用户头像
*/
private String userAvatar;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户角色user/admin/ban
*/
private String userRole;
/**
* 用户角色user/admin/ban
*/
private String userRole;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 用户邮箱
*/
private String userEmail;
/**
* 更新时间
*/
private Date updateTime;
/**
* 用户邮箱是否验证 0 未验证 1已验证
*/
private Integer userEmailVerified;
/**
* 是否删除
*/
@TableLogic
private Integer isDelete;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/**
* 更新时间
*/
private Date updateTime;
/**
* 是否删除
*/
@TableLogic
private Integer isDelete;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,15 @@
package cn.meowrain.aioj.backend.userservice.dto.req;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 用户注册的邮箱 请求对象
*/
@Data
public class EmailSendCodeRequestDTO {
@NotNull
@Email
String email;
}

View File

@@ -1,5 +1,6 @@
package cn.meowrain.aioj.backend.userservice.dto.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@@ -8,61 +9,85 @@ import java.util.Date;
* 用户认证响应体
*/
@Data
@Schema(description = "用户认证响应体")
public class UserAuthRespDTO {
/**
* id
*/
@Schema(description = "用户ID", example = "1")
private Long id;
/**
* 用户账号
*/
@Schema(description = "用户账号", example = "admin")
private String userAccount;
/**
* 用户密码
*/
@Schema(description = "用户密码", example = "123456")
private String userPassword;
/**
* 开放平台id
*/
@Schema(description = "开放平台ID", example = "wx_union_id_123")
private String unionId;
/**
* 公众号openId
*/
@Schema(description = "公众号OpenID", example = "wx_openid_123")
private String mpOpenId;
/**
* 用户昵称
*/
@Schema(description = "用户昵称", example = "张三")
private String userName;
/**
* 用户头像
*/
@Schema(description = "用户头像URL", example = "https://example.com/avatar.jpg")
private String userAvatar;
/**
* 用户简介
*/
@Schema(description = "用户简介", example = "这是我的个人简介")
private String userProfile;
/**
* 用户角色user/admin/ban
*/
@Schema(description = "用户角色", example = "user", allowableValues = {"user", "admin", "ban"})
private String userRole;
/**
* 用户邮箱
*/
@Schema(description = "用户邮箱", example = "user@example.com")
private String userEmail;
/**
* 用户邮箱是否验证 0 未验证 1已验证
*/
@Schema(description = "用户邮箱是否验证", example = "1", allowableValues = {"0", "1"})
private Integer userEmailVerified;
/**
* 创建时间
*/
@Schema(description = "创建时间", example = "2025-01-01T00:00:00")
private Date createTime;
/**
* 更新时间
*/
@Schema(description = "更新时间", example = "2025-01-01T12:00:00")
private Date updateTime;
}

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.userservice.service;
/**
* 邮件服务接口
*/
public interface EmailService {
/**
* 发送邮箱验证码
* @param email 收件人邮箱
*/
void sendVerifyCode(String email);
}

View File

@@ -28,4 +28,19 @@ public interface UserService extends IService<User> {
*/
UserAuthRespDTO findAuthInfoByUserId(String userId);
/**
* 发送邮箱验证码
*/
void sendEmailCode(String email);
/**
* 绑定邮箱
*/
void bindEmail(String email, String code);
/**
* 解绑邮箱
*/
void unbindEmail(Long userId);
}

View File

@@ -0,0 +1,124 @@
package cn.meowrain.aioj.backend.userservice.service.impl;
import cn.meowrain.aioj.backend.framework.core.exception.ServiceException;
import cn.meowrain.aioj.backend.userservice.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.userservice.service.EmailService;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
/**
* 邮件服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements EmailService {
private final JavaMailSender mailSender;
private final StringRedisTemplate redisTemplate;
@Value("${spring.mail.username}")
private String FROM_EMAIL;
private static final String SUBJECT = "【AIOJ】邮箱验证码";
private static final int CODE_LENGTH = 6;
/**
* 生成6位随机验证码
*/
private String generateCode() {
SecureRandom random = new SecureRandom();
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
stringBuilder.append(random.nextInt(10));
}
return stringBuilder.toString();
}
/**
* 构建邮件内容
*/
private String buildEmailContent(String code) {
return String.format("""
<html>
<body style="font-family: Arial, Helvetica, 'Microsoft YaHei', sans-serif; background-color: #f4f6f8; margin: 0; padding: 0;">
<table width="100%%" cellpadding="0" cellspacing="0" style="padding: 30px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.06);">
<tr>
<td style="padding: 24px 32px; border-bottom: 1px solid #e6e8eb;">
<h2 style="margin: 0; font-size: 20px; font-weight: 600; color: #1f2937;">AIOJ 邮箱验证</h2>
</td>
</tr>
<tr>
<td style="padding: 24px 32px; color: #374151; font-size: 14px;">
<p style="margin: 0 0 12px 0;">您好,</p>
<p style="margin: 0 0 16px 0;">您正在进行邮箱安全校验,为保障账户安全,请使用以下验证码:</p>
<div style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px; text-align: center; margin: 20px 0;">
<span style="font-size: 28px; font-weight: bold; letter-spacing: 2px; color: #2563eb;">%s</span>
</div>
<p style="margin: 0 0 16px 0;">验证码有效期为 <strong>1 分钟</strong>,请尽快完成操作。</p>
<p style="margin: 0 0 8px 0; color: #6b7280;">若非本人操作,请忽略本邮件。</p>
</td>
</tr>
<tr>
<td style="padding: 20px 32px; border-top: 1px solid #e6e8eb; background: #fafbfc; color: #9ca3af; font-size: 12px; text-align: center;">
<p style="margin: 0 0 4px 0;">此邮件由系统自动发送,请勿回复</p>
<p style="margin: 0;">AIOJ © %s All Rights Reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""", code, java.time.Year.now());
}
@Override
public void sendVerifyCode(String email) {
// 生成验证码
String code = generateCode();
// 验证码存redis里面1分钟过期时间
String redisKey = String.format(RedisKeyConstants.EMAIL_CODE_PREFIX,email);
redisTemplate.opsForValue().set(redisKey,code,RedisKeyConstants.EMAIL_CODE_TTL,TimeUnit.SECONDS);
// 发送邮件
try {
MimeMessage message = mailSender.createMimeMessage();
// ⭐ 显式指定 UTF-8 编码,解决中文乱码问题
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(FROM_EMAIL);
helper.setTo(email);
helper.setSubject(SUBJECT);
helper.setText(buildEmailContent(code), true);
// ⭐ 发送邮件
mailSender.send(message);
log.info("验证码邮件发送成功: 邮箱={}, 验证码={}", email, code);
}catch (MessagingException e) {
log.error("验证码邮件发送失败: 邮箱={}", email, e);
throw new ServiceException("邮件发送失败,请稍后重试");
}
}
}

View File

@@ -4,6 +4,7 @@ import cn.hutool.crypto.digest.BCrypt;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.framework.core.exception.ServiceException;
import cn.meowrain.aioj.backend.framework.core.utils.ContextHolderUtils;
import cn.meowrain.aioj.backend.userservice.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.userservice.dao.entity.User;
import cn.meowrain.aioj.backend.userservice.dao.mapper.UserMapper;
@@ -11,6 +12,7 @@ import cn.meowrain.aioj.backend.userservice.dto.chains.context.UserRegisterReque
import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.userservice.service.EmailService;
import cn.meowrain.aioj.backend.userservice.service.UserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
@@ -28,6 +30,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private final UserRegisterRequestParamVerifyContext userRegisterRequestParamVerifyContext;
private final EmailService emailService;
@Override
public Long userRegister(UserRegisterRequestDTO request) {
UserAuthRespDTO authInfoByUserAccount = findAuthInfoByUserAccount(request.getUserAccount());
@@ -82,4 +86,21 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
}
@Override
public void sendEmailCode(String email) {
emailService.sendVerifyCode(email);
}
@Override
public void bindEmail(String email, String code) {
Long currentUserId = ContextHolderUtils.getCurrentUserId();
}
@Override
public void unbindEmail(Long userId) {
}
}

View File

@@ -1,4 +1,20 @@
spring:
mail:
host: smtp.qq.com
port: 465
username: 2705356115@qq.com
# 这里使用授权码
password: yohcndfrlxwcdfed
default-encoding: UTF-8
protocol: smtp
properties:
mail:
smtp:
ssl:
enable: true # 在 properties 中明确指定
auth: true
starttls:
enable: true # QQ邮箱也支持STARTTLS但使用465端口时ssl.enable=true是必须的
data:
redis:
host: 10.0.0.10