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:
2026-03-31 08:54:06 +08:00
commit 3a9bf61839
50 changed files with 3098 additions and 0 deletions

View File

@@ -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()
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}