feat: 初始化 Spring Boot 项目模板,搭建完整的用户认证与管理系统
- 新增项目基础配置:pom.xml 依赖管理、多环境配置(dev/prod)、Dockerfile、.env.example - 新增安全认证模块:JWT 工具类、JWT 过滤器、Spring Security 配置、自定义 UserDetails - 新增用户管理功能:注册/登录/查询/修改、角色管理(USER/ADMIN/ROOT)、分页查询、状态启禁用 - 新增密码重置功能:邮箱验证码发送、验证码校验重置、频率限制与过期机制 - 新增基础架构层:统一响应体 RestBean、全局异常处理、日志拦截器、Redis 工具类、JPA 配置 - 新增 Swagger/OpenAPI 文档配置与完整的 API 接口文档(API_DOCUMENT.md) - 新增数据库初始化 SQL 脚本(init.sql)
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.CustomUserDetails;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
|
||||
|
||||
// 检查用户状态
|
||||
if (!user.isEnabled()) {
|
||||
throw new UsernameNotFoundException("用户已被禁用: " + username);
|
||||
}
|
||||
|
||||
return new CustomUserDetails(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getPassword(),
|
||||
List.of(() -> user.getRole().getAuthority()),
|
||||
user.isEnabled(),
|
||||
user.getRole().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.service.EmailService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmailServiceImpl implements EmailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String from;
|
||||
|
||||
@Value("${app.password-reset.code-expire-minutes:10}")
|
||||
private Integer expireMinutes;
|
||||
|
||||
@Override
|
||||
public void sendPasswordResetCode(String email, String code) {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(from);
|
||||
message.setTo(email);
|
||||
message.setSubject("密码找回验证码");
|
||||
message.setText("""
|
||||
您正在进行密码找回操作。
|
||||
|
||||
验证码:%s
|
||||
有效期:%d 分钟
|
||||
|
||||
如果这不是您的操作,请忽略此邮件。
|
||||
""".formatted(code, expireMinutes));
|
||||
mailSender.send(message);
|
||||
log.info("已发送密码找回验证码到邮箱: {}", email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.RestCode;
|
||||
import com.aisi.template.domain.dto.PasswordResetConfirmDto;
|
||||
import com.aisi.template.domain.dto.PasswordResetRequestDto;
|
||||
import com.aisi.template.domain.entity.PasswordResetCode;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.repository.PasswordResetCodeRepository;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.EmailService;
|
||||
import com.aisi.template.service.PasswordResetService;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordResetServiceImpl implements PasswordResetService {
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final EmailService emailService;
|
||||
|
||||
@Value("${app.password-reset.code-expire-minutes:10}")
|
||||
private Integer expireMinutes;
|
||||
|
||||
@Value("${app.password-reset.request-cooldown-seconds:60}")
|
||||
private Integer cooldownSeconds;
|
||||
|
||||
@Value("${app.password-reset.max-attempts:5}")
|
||||
private Integer maxAttempts;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto) {
|
||||
String email = requestDto.getEmail().trim().toLowerCase();
|
||||
|
||||
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
|
||||
}
|
||||
|
||||
Optional<PasswordResetCode> latestCode = passwordResetCodeRepository
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email);
|
||||
if (latestCode.isPresent()) {
|
||||
LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds);
|
||||
if (nextAllowedAt.isAfter(LocalDateTime.now())) {
|
||||
long seconds = java.time.Duration.between(LocalDateTime.now(), nextAllowedAt).getSeconds();
|
||||
return RestBean.failure(429, "请求过于频繁,请 " + Math.max(seconds, 1) + " 秒后重试", null);
|
||||
}
|
||||
}
|
||||
|
||||
long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(email, LocalDateTime.now().minusHours(1));
|
||||
if (recentCount >= 5) {
|
||||
return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null);
|
||||
}
|
||||
|
||||
List<PasswordResetCode> activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
|
||||
for (PasswordResetCode item : activeCodes) {
|
||||
item.setUsed(true);
|
||||
}
|
||||
passwordResetCodeRepository.saveAll(activeCodes);
|
||||
|
||||
String code = generateCode();
|
||||
PasswordResetCode resetCode = new PasswordResetCode();
|
||||
resetCode.setEmail(email);
|
||||
resetCode.setCodeHash(sha256(code));
|
||||
resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes));
|
||||
resetCode.setUsed(false);
|
||||
resetCode.setAttemptCount(0);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
|
||||
emailService.sendPasswordResetCode(email, code);
|
||||
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto) {
|
||||
String email = confirmDto.getEmail().trim().toLowerCase();
|
||||
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
return RestBean.failure(400, "验证码或邮箱不正确", null);
|
||||
}
|
||||
|
||||
PasswordResetCode resetCode = passwordResetCodeRepository
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email)
|
||||
.orElse(null);
|
||||
if (resetCode == null) {
|
||||
return RestBean.failure(400, "请先获取验证码", null);
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(resetCode.getUsed())) {
|
||||
return RestBean.failure(400, "验证码已失效,请重新获取", null);
|
||||
}
|
||||
|
||||
if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
return RestBean.failure(400, "验证码已过期,请重新获取", null);
|
||||
}
|
||||
|
||||
if (resetCode.getAttemptCount() >= maxAttempts) {
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null);
|
||||
}
|
||||
|
||||
if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) {
|
||||
resetCode.setAttemptCount(resetCode.getAttemptCount() + 1);
|
||||
if (resetCode.getAttemptCount() >= maxAttempts) {
|
||||
resetCode.setUsed(true);
|
||||
}
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
return RestBean.failure(400, "验证码不正确", null);
|
||||
}
|
||||
|
||||
User user = userOptional.get();
|
||||
user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
|
||||
List<PasswordResetCode> otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
|
||||
for (PasswordResetCode item : otherCodes) {
|
||||
item.setUsed(true);
|
||||
}
|
||||
passwordResetCodeRepository.saveAll(otherCodes);
|
||||
|
||||
log.info("用户通过邮箱验证码重置密码成功: {}", email);
|
||||
return RestBean.success(RestCode.SUCCESS);
|
||||
}
|
||||
|
||||
private String generateCode() {
|
||||
return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000));
|
||||
}
|
||||
|
||||
private String sha256(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
builder.append('0');
|
||||
}
|
||||
builder.append(hex);
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("无法生成验证码哈希", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.RestCode;
|
||||
import com.aisi.template.domain.dto.PageResult;
|
||||
import com.aisi.template.domain.dto.UserDto;
|
||||
import com.aisi.template.domain.dto.UserQueryDto;
|
||||
import com.aisi.template.domain.dto.UserRoleUpdateDto;
|
||||
import com.aisi.template.domain.dto.UserStatusUpdateDto;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.domain.enums.Role;
|
||||
import com.aisi.template.domain.vo.UserVo;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.UserService;
|
||||
import com.aisi.template.utils.JwtUtil;
|
||||
import com.aisi.template.utils.SecurityUtils;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserRepository userRepository;
|
||||
private final SecurityUtils securityUtils;
|
||||
|
||||
@Override
|
||||
public RestBean<UserVo> getUserInfo() {
|
||||
String username = SecurityUtils.getUsername();
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElse(null);
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND);
|
||||
}
|
||||
// 转换为 UserVo 返回
|
||||
UserVo userVo = new UserVo();
|
||||
userVo.setId(user.getId());
|
||||
userVo.setUsername(user.getUsername());
|
||||
userVo.setEmail(user.getEmail());
|
||||
userVo.setStatus(user.getStatus());
|
||||
userVo.setRole(user.getRole().name());
|
||||
userVo.setCreatedAt(user.getCreatedAt());
|
||||
userVo.setUpdatedAt(user.getUpdatedAt());
|
||||
return RestBean.success(userVo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestBean<String> register(UserDto userDto) {
|
||||
String normalizedEmail = userDto.getEmail() == null ? null : userDto.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||
// 检查用户名是否存在
|
||||
if (userRepository.existsByUsername(userDto.getUsername())) {
|
||||
return RestBean.failure(400, "用户名已被使用", null);
|
||||
}
|
||||
// 检查邮箱是否存在
|
||||
if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) {
|
||||
return RestBean.failure(400, "邮箱已被使用", null);
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(userDto.getUsername());
|
||||
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
|
||||
user.setEmail(normalizedEmail);
|
||||
user.setRole(Role.USER); // 新注册用户默认为普通用户
|
||||
// 默认状态为1(正常),已在实体类中设置
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
// 生成token
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
return RestBean.success(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestBean<String> login(UserDto userDto) {
|
||||
// 查找用户
|
||||
User user = userRepository.findByUsername(userDto.getUsername())
|
||||
.orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, "用户不存在");
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (!user.isEnabled()) {
|
||||
return RestBean.failure(403, "用户已被禁用", null);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) {
|
||||
return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
|
||||
}
|
||||
|
||||
// 生成token
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
return RestBean.success(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
|
||||
try {
|
||||
Pageable pageable = PageRequest.of(
|
||||
Math.max(queryDto.getPage() - 1, 0),
|
||||
Math.max(queryDto.getSize(), 1),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt")
|
||||
);
|
||||
|
||||
Page<User> userPage = userRepository.findAll(buildSpecification(queryDto), pageable);
|
||||
List<UserVo> records = userPage.getContent().stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return RestBean.success(PageResult.of(
|
||||
records,
|
||||
userPage.getTotalElements(),
|
||||
queryDto.getPage(),
|
||||
queryDto.getSize()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.error("获取用户列表失败", e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto) {
|
||||
try {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
|
||||
}
|
||||
|
||||
Long currentUserId = SecurityUtils.getUserId();
|
||||
if (currentUserId != null && currentUserId.equals(userId)) {
|
||||
return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null);
|
||||
}
|
||||
|
||||
user.setStatus(updateDto.getStatus());
|
||||
return RestBean.success(convertToVo(userRepository.save(user)));
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户状态失败, userId={}", userId, e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto) {
|
||||
try {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
|
||||
}
|
||||
|
||||
Long currentUserId = SecurityUtils.getUserId();
|
||||
if (currentUserId != null && currentUserId.equals(userId)) {
|
||||
return RestBean.failure(403, "不允许修改当前登录用户的角色", null);
|
||||
}
|
||||
|
||||
String roleValue = updateDto.getRole().trim().toUpperCase();
|
||||
if (!"ADMIN".equals(roleValue) && !"USER".equals(roleValue)) {
|
||||
return RestBean.failure(400, "角色不合法", null);
|
||||
}
|
||||
|
||||
Role role = Role.valueOf(roleValue);
|
||||
user.setRole(role);
|
||||
return RestBean.success(convertToVo(userRepository.save(user)));
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户角色失败, userId={}", userId, e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Specification<User> buildSpecification(UserQueryDto queryDto) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
if (StringUtils.hasText(queryDto.getKeyword())) {
|
||||
String keyword = "%" + queryDto.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("username"), keyword),
|
||||
cb.like(root.get("email"), keyword)
|
||||
));
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(queryDto.getRole())) {
|
||||
predicates.add(cb.equal(root.get("role"), Role.fromString(queryDto.getRole())));
|
||||
}
|
||||
|
||||
if (queryDto.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), queryDto.getStatus()));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private UserVo convertToVo(User user) {
|
||||
UserVo userVo = new UserVo();
|
||||
userVo.setId(user.getId());
|
||||
userVo.setUsername(user.getUsername());
|
||||
userVo.setEmail(user.getEmail());
|
||||
userVo.setStatus(user.getStatus());
|
||||
userVo.setRole(user.getRole().name());
|
||||
userVo.setCreatedAt(user.getCreatedAt());
|
||||
userVo.setUpdatedAt(user.getUpdatedAt());
|
||||
return userVo;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user