feat: 实现用户邮箱管理和个人资料功能

- 修复邮箱验证码接口参数绑定问题 (@RequestParam -> @ModelAttribute)
- 实现异步邮件发送,使用独立线程池避免阻塞
- 完成邮箱绑定/解绑功能
- 实现修改密码功能
- 实现用户资料查询和更新功能
- 添加个人资料相关 DTO
- 更新网关过滤器使用新的服务名称

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-18 17:31:08 +08:00
parent c3c07ff1e7
commit 08043672f9
11 changed files with 310 additions and 88 deletions

View File

@@ -105,7 +105,7 @@ public class AuthGlobalFilter implements GlobalFilter, Ordered {
private Mono<Boolean> validateToken(String token) { private Mono<Boolean> validateToken(String token) {
return webClientBuilder.build() return webClientBuilder.build()
.post() .post()
.uri("lb://auth-service/api/v1/auth/validate") .uri("lb://aioj-auth-service/api/v1/auth/validate")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.retrieve() .retrieve()

View File

@@ -0,0 +1,40 @@
package cn.meowrain.aioj.backend.userservice.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步配置类
*
* @author meowrain
* @since 2026-01-18
*/
@Slf4j
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(2);
// 最大线程数
executor.setMaxPoolSize(5);
// 队列容量
executor.setQueueCapacity(100);
// 线程名前缀
executor.setThreadNamePrefix("email-async-");
// 拒绝策略:由调用线程执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
log.info("邮件异步线程池初始化完成: coreSize=2, maxSize=5, queueCapacity=100");
return executor;
}
}

View File

@@ -1,13 +1,10 @@
package cn.meowrain.aioj.backend.userservice.controller; package cn.meowrain.aioj.backend.userservice.controller;
import cn.meowrain.aioj.backend.framework.core.utils.ContextHolderUtils;
import cn.meowrain.aioj.backend.framework.core.web.Result; import cn.meowrain.aioj.backend.framework.core.web.Result;
import cn.meowrain.aioj.backend.framework.core.web.Results; import cn.meowrain.aioj.backend.framework.core.web.Results;
import cn.meowrain.aioj.backend.userservice.dto.req.AvatarUpdateRequestDTO; import cn.meowrain.aioj.backend.userservice.dto.req.*;
import cn.meowrain.aioj.backend.userservice.dto.req.BindEmailRequest;
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.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserProfileRespDTO;
import cn.meowrain.aioj.backend.userservice.service.UserService; import cn.meowrain.aioj.backend.userservice.service.UserService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -15,7 +12,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor @RequiredArgsConstructor
@RestController() @RestController()
@@ -60,7 +56,7 @@ public class UserController {
@Operation(summary = "发送验证码", description = "根据用户注册的邮箱发送验证码") @Operation(summary = "发送验证码", description = "根据用户注册的邮箱发送验证码")
@GetMapping("/email/send-code") @GetMapping("/email/send-code")
public Result<Void> getVerifyCode(@Parameter(description = "邮箱信息", required = true) public Result<Void> getVerifyCode(@Parameter(description = "邮箱信息", required = true)
@Valid @RequestParam EmailSendCodeRequestDTO request) { @Valid @ModelAttribute EmailSendCodeRequestDTO request) {
userService.sendEmailCode(request.getEmail()); userService.sendEmailCode(request.getEmail());
return Results.success(null); return Results.success(null);
} }
@@ -73,8 +69,8 @@ public class UserController {
*/ */
@Operation(summary = "绑定邮箱", description = "根据用户注册的邮箱绑定邮箱") @Operation(summary = "绑定邮箱", description = "根据用户注册的邮箱绑定邮箱")
@PostMapping("/email/bind") @PostMapping("/email/bind")
public Result<Void> bindEmail(@RequestBody BindEmailRequest request) { public Result<Void> bindEmail(@RequestBody @Valid BindEmailRequest request) {
userService.bindEmail(request.getEmail(), request.getCode()); userService.bindEmail(request.getEmail(), request.getVerifyCode());
return Results.success(null); return Results.success(null);
} }
@@ -86,20 +82,22 @@ public class UserController {
@Operation(summary = "解绑邮箱", description = "根据用户注册的邮箱解绑邮箱") @Operation(summary = "解绑邮箱", description = "根据用户注册的邮箱解绑邮箱")
@PostMapping("/email/unbind") @PostMapping("/email/unbind")
public Result<Void> unbindEmail() { public Result<Void> unbindEmail() {
userService.unbindEmail(ContextHolderUtils.getCurrentUserId()); userService.unbindEmail();
return Results.success(null); return Results.success(null);
} }
@Operation(summary = "个人资料管理", description = "获取完整个人资料") @Operation(summary = "个人资料管理", description = "获取完整个人资料")
@GetMapping("/profile") @GetMapping("/profile")
public Result<Void> getUserProfile() { public Result<UserProfileRespDTO> getUserProfile() {
return Results.success(); UserProfileRespDTO userProfileRespDTO = userService.getUserProfile();
return Results.success(userProfileRespDTO);
} }
@Operation(summary = "个人资料管理-更新个人资料", description = "更新个人资料") @Operation(summary = "个人资料管理-更新个人资料", description = "更新个人资料")
@PutMapping("/profile") @PutMapping("/profile")
public Result<Void> updateUserProfile() { public Result<Void> updateUserProfile(@RequestBody UserProfileUpdateRequestDTO dto) {
userService.updateUserProfile(dto);
return Results.success(); return Results.success();
} }
@@ -110,8 +108,10 @@ public class UserController {
return Results.success(); return Results.success();
} }
@PutMapping("/password")
@Operation(summary = "个人资料管理-修改密码",description = "修改密码") @Operation(summary = "个人资料管理-修改密码",description = "修改密码")
public Result<Void> changePassword() { public Result<Void> changePassword(@RequestBody ChangePasswordRequestDTO dto) {
userService.changePassword(dto);
return Results.success(); return Results.success();
} }
} }

View File

@@ -12,5 +12,5 @@ public class BindEmailRequest {
@Schema(description = "邮箱",example = "123@qq.com") @Schema(description = "邮箱",example = "123@qq.com")
private String email; private String email;
@Schema(description = "验证码",example = "123456") @Schema(description = "验证码",example = "123456")
private String code; private String verifyCode;
} }

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.userservice.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "修改密码请求参数")
public class ChangePasswordRequestDTO {
@Schema(description = "旧密码")
private String oldPassword;
@Schema(description = "新密码")
private String newPassword;
}

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.userservice.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "用户个人资料更新请求DTO")
public class UserProfileUpdateRequestDTO {
@Schema(description = "用户昵称")
private String userName;
@Schema(description = "用户简介")
private String userProfile;
}

View File

@@ -0,0 +1,10 @@
package cn.meowrain.aioj.backend.userservice.dto.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "用户个人资料响应DTO")
public class UserProfileRespDTO {
}

View File

@@ -10,4 +10,11 @@ public interface EmailService {
* @param email 收件人邮箱 * @param email 收件人邮箱
*/ */
void sendVerifyCode(String email); void sendVerifyCode(String email);
/**
* 获取邮箱验证码
* @param email 收件人邮箱
* @return 验证码
*/
String getVerifyCode(String email);
} }

View File

@@ -2,8 +2,11 @@ package cn.meowrain.aioj.backend.userservice.service;
import cn.meowrain.aioj.backend.userservice.dao.entity.User; import cn.meowrain.aioj.backend.userservice.dao.entity.User;
import cn.meowrain.aioj.backend.userservice.dto.req.ChangePasswordRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.req.UserProfileUpdateRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO; import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO; import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserProfileRespDTO;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
public interface UserService extends IService<User> { public interface UserService extends IService<User> {
@@ -42,7 +45,7 @@ public interface UserService extends IService<User> {
/** /**
* 解绑邮箱 * 解绑邮箱
*/ */
void unbindEmail(Long userId); void unbindEmail();
/** /**
* 设置用户头像 * 设置用户头像
@@ -50,4 +53,19 @@ public interface UserService extends IService<User> {
* @return * @return
*/ */
void setProfileAvatar(Long fileId); void setProfileAvatar(Long fileId);
/**
* 修改密码
*/
void changePassword(ChangePasswordRequestDTO dto);
/**
* 更新用户个人资料
*/
void updateUserProfile(UserProfileUpdateRequestDTO dto);
/**
* 获取用户个人资料
*/
UserProfileRespDTO getUserProfile();
} }

View File

@@ -1,5 +1,6 @@
package cn.meowrain.aioj.backend.userservice.service.impl; package cn.meowrain.aioj.backend.userservice.service.impl;
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.exception.ServiceException;
import cn.meowrain.aioj.backend.userservice.common.constants.RedisKeyConstants; import cn.meowrain.aioj.backend.userservice.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.userservice.service.EmailService; import cn.meowrain.aioj.backend.userservice.service.EmailService;
@@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
@@ -95,6 +97,7 @@ public class EmailServiceImpl implements EmailService {
} }
@Async("emailExecutor")
@Override @Override
public void sendVerifyCode(String email) { public void sendVerifyCode(String email) {
// 生成验证码 // 生成验证码
@@ -124,4 +127,22 @@ public class EmailServiceImpl implements EmailService {
} }
} }
/**
* 获取邮箱验证码
* @param email 收件人邮箱
* @return 验证码
*/
@Override
public String getVerifyCode(String email) {
String redisKey = String.format(RedisKeyConstants.EMAIL_CODE_PREFIX,email);
// 从redis里面获取验证码,用Object接收,因为redis里面可能存储的是null 比如过期这种情况
Object verifyCodeInSystem = redisTemplate.opsForValue().get(redisKey);
if(verifyCodeInSystem == null) {
throw new ClientException("验证码不存在或已过期");
}
// 转换为字符串
return verifyCodeInSystem.toString();
}
} }

View File

@@ -10,8 +10,11 @@ import cn.meowrain.aioj.backend.userservice.dao.entity.User;
import cn.meowrain.aioj.backend.userservice.dao.mapper.UserMapper; import cn.meowrain.aioj.backend.userservice.dao.mapper.UserMapper;
import cn.meowrain.aioj.backend.userservice.dto.chains.context.UserRegisterRequestParamVerifyContext; import cn.meowrain.aioj.backend.userservice.dto.chains.context.UserRegisterRequestParamVerifyContext;
import cn.meowrain.aioj.backend.userservice.dto.req.ChangePasswordRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.req.UserProfileUpdateRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO; import cn.meowrain.aioj.backend.userservice.dto.req.UserRegisterRequestDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO; import cn.meowrain.aioj.backend.userservice.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.userservice.dto.resp.UserProfileRespDTO;
import cn.meowrain.aioj.backend.userservice.service.EmailService; import cn.meowrain.aioj.backend.userservice.service.EmailService;
import cn.meowrain.aioj.backend.userservice.service.UserService; import cn.meowrain.aioj.backend.userservice.service.UserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -55,8 +58,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
try { try {
// 需要修改表,使得用户名是唯一的 // 需要修改表,使得用户名是唯一的
this.save(user); this.save(user);
} } catch (DuplicateKeyException e) {
catch (DuplicateKeyException e) {
log.error("重复创建用户"); log.error("重复创建用户");
throw new ServiceException("用户名已存在", ErrorCode.SYSTEM_ERROR); throw new ServiceException("用户名已存在", ErrorCode.SYSTEM_ERROR);
} }
@@ -94,15 +96,56 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
@Override @Override
public void bindEmail(String email, String code) { public void bindEmail(String email, String code) {
if (email == null || code == null) {
throw new ClientException("邮箱或验证码不能为空");
}
Long currentUserId = ContextHolderUtils.getCurrentUserId(); Long currentUserId = ContextHolderUtils.getCurrentUserId();
// 检查用户是否存在
User user = this.lambdaQuery().eq(User::getId, currentUserId).one();
if (user == null) {
throw new ClientException("用户不存在");
}
// 验证邮箱验证码
// 从redis里面获取验证码
String verifyCodeInSystem = emailService.getVerifyCode(email);
// 验证验证码是否匹配
if (!verifyCodeInSystem.equals(code)) {
throw new ClientException("验证码错误,请重新输入");
}
// 绑定邮箱
user.setUserEmail(email);
user.setUpdateTime(new Date());
this.updateById(user);
} }
@Override @Override
public void unbindEmail(Long userId) { public void unbindEmail() {
User one = this.lambdaQuery().eq(User::getId, userId).one(); Long currentUserId = ContextHolderUtils.getCurrentUserId();
User user = this.lambdaQuery().eq(User::getId, currentUserId).one();
if (user == null) {
throw new ClientException("用户不存在");
}
if (user.getUserEmail() == null) {
throw new ClientException("邮箱未绑定");
}
// 这种方法避免了查询整个对象,并且能精确控制 NULL 值的更新。
boolean success = this.lambdaUpdate()
// 设置 userEmail 为 NULL
.set(User::getUserEmail, null)
// 设置 updateTime 为当前时间
.set(User::getUpdateTime, new Date())
// 筛选条件用户ID
.eq(User::getId, currentUserId)
// 执行更新
.update();
if (!success) {
throw new ClientException("解绑失败,请重试");
}
} }
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -115,4 +158,61 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
this.updateById(user); this.updateById(user);
} }
@Transactional(rollbackFor = Exception.class)
@Override
public void changePassword(ChangePasswordRequestDTO dto) {
Long currentUserId = ContextHolderUtils.getCurrentUserId();
User user = this.lambdaQuery().eq(User::getId, currentUserId).one();
// 检查用户是否存在
if (user == null) {
throw new ClientException("用户不存在");
}
// 检查旧密码是否正确
String oldPassword = user.getUserPassword();
if (!BCrypt.checkpw(dto.getOldPassword(), oldPassword)) {
throw new ClientException("旧密码错误");
}
// 检查新密码是否与旧密码相同
if (BCrypt.checkpw(dto.getNewPassword(), oldPassword)) {
throw new ClientException("新密码不能与旧密码相同");
}
// 加密新密码
Date now = new Date();
String salt = BCrypt.gensalt();
String encryptPassword = BCrypt.hashpw(dto.getNewPassword(), salt);
// 更新用户密码
user.setUserPassword(encryptPassword);
user.setUpdateTime(now);
this.updateById(user);
}
@Override
public void updateUserProfile(UserProfileUpdateRequestDTO dto) {
Long currentUserId = ContextHolderUtils.getCurrentUserId();
User user = this.lambdaQuery().eq(User::getId, currentUserId).one();
if (user == null) {
throw new ClientException("用户不存在");
}
// 更新用户个人资料
user.setUserName(dto.getUserName());
user.setUserProfile(dto.getUserProfile());
user.setUpdateTime(new Date());
this.updateById(user);
}
/**
* 获取用户个人资料
* @return 用户个人资料
*/
@Override
public UserProfileRespDTO getUserProfile() {
// TODO: 待实现 从数据库查询用户个人资料
return null;
}
} }