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,13 @@
package com.aisi.template;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TemplateApplication {
public static void main(String[] args) {
SpringApplication.run(TemplateApplication.class, args);
System.out.println("Swagger UI: http://localhost:8080/swagger-ui/index.html");
}
}

View File

@@ -0,0 +1,34 @@
package com.aisi.template.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// 配置日期时间格式
JavaTimeModule javaTimeModule = new JavaTimeModule();
// 定义日期时间格式
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
mapper.registerModule(javaTimeModule);
// 禁用时间戳格式
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}

View File

@@ -0,0 +1,12 @@
package com.aisi.template.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 配置类
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

View File

@@ -0,0 +1,15 @@
package com.aisi.template.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springframework.context.annotation.Configuration;
@Configuration
@SecurityScheme(
name = "Bearer Authentication",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
public class OpenApiConfig {
}

View File

@@ -0,0 +1,48 @@
package com.aisi.template.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper redisMapper = objectMapper.copy();
redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
redisMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper);
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}

View File

@@ -0,0 +1,66 @@
package com.aisi.template.config;
import com.aisi.template.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll()
.requestMatchers("/api/v1/user/password-reset/**").permitAll()
.requestMatchers("/api/v1/user/info").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setExposedHeaders(List.of("*"));
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,22 @@
package com.aisi.template.config;
import com.aisi.template.interceptor.LoggingInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoggingInterceptor loggingInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loggingInterceptor)
.addPathPatterns("/**")
.excludePathPatterns();
}
}

View File

@@ -0,0 +1,87 @@
package com.aisi.template.controller;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.dto.PageResult;
import com.aisi.template.domain.dto.PasswordResetConfirmDto;
import com.aisi.template.domain.dto.PasswordResetRequestDto;
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.vo.UserVo;
import com.aisi.template.service.PasswordResetService;
import com.aisi.template.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
@Tag(name = "用户接口")
public class UserController {
private final UserService userService;
private final PasswordResetService passwordResetService;
@GetMapping("info")
@Operation(summary = "用户信息")
@SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> getUserInfo() {
return userService.getUserInfo();
}
@PostMapping("register")
@Operation(summary = "用户注册")
public RestBean<String> register(@Valid @RequestBody UserDto userDto) {
return userService.register(userDto);
}
@PostMapping("login")
@Operation(summary = "用户登录")
RestBean<String> login(@Valid @RequestBody UserDto userDto) {
return userService.login(userDto);
}
@PostMapping("password-reset/request")
@Operation(summary = "发送找回密码验证码")
public RestBean<Void> sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) {
return passwordResetService.sendResetCode(requestDto);
}
@PostMapping("password-reset/confirm")
@Operation(summary = "验证码重置密码")
public RestBean<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) {
return passwordResetService.resetPassword(confirmDto);
}
@GetMapping("list")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "分页获取用户列表")
@SecurityRequirement(name = "Bearer Authentication")
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
return userService.getUserList(queryDto);
}
@PutMapping("{userId}/status")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "更新用户状态")
@SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> updateUserStatus(@PathVariable Long userId,
@Valid @RequestBody UserStatusUpdateDto updateDto) {
return userService.updateUserStatus(userId, updateDto);
}
@PutMapping("{userId}/role")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "更新用户角色")
@SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> updateUserRole(@PathVariable Long userId,
@Valid @RequestBody UserRoleUpdateDto updateDto) {
return userService.updateUserRole(userId, updateDto);
}
}

View File

@@ -0,0 +1,63 @@
package com.aisi.template.domain;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
public class CustomUserDetails implements UserDetails {
@Getter
private final Long id;
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean enabled;
@Getter
private final String role;
public CustomUserDetails(Long id, String username, String password, Collection<? extends GrantedAuthority> authorities, boolean enabled, String role) {
this.id = id;
this.username = username;
this.password = password;
this.authorities = authorities;
this.enabled = enabled;
this.role = role;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}

View File

@@ -0,0 +1,118 @@
package com.aisi.template.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用的 REST API 响应封装类
*
* @param <V> 返回数据的类型
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RestBean<V> {
/** 状态码 */
private int code;
/** 提示消息 */
private String message;
/** 具体数据 */
private V data;
/**
* 成功响应(默认使用 {@link RestCode#SUCCESS}
*
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> success(V data) {
return success(RestCode.SUCCESS, data);
}
/**
* 成功响应(指定 RestCode 和数据)
*
* @param restCode 状态码枚举
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> success(RestCode restCode, V data) {
return success(restCode.getCode(), restCode.getMessage(), data);
}
/**
* 成功响应(只返回状态码和消息,不带数据)
*
* @param restCode 状态码枚举
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> success(RestCode restCode) {
return success(restCode, null);
}
/**
* 成功响应(自定义 code 和 message
*
* @param code 状态码
* @param message 提示消息
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> success(Integer code, String message, V data) {
return new RestBean<>(code, message, data);
}
/**
* 失败响应(默认使用 {@link RestCode#FAILURE}
*
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> failure(V data) {
return new RestBean<>(RestCode.FAILURE.getCode(), RestCode.FAILURE.getMessage(), data);
}
/**
* 失败响应(指定 RestCode 和数据)
*
* @param restCode 状态码枚举
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> failure(RestCode restCode, V data) {
return new RestBean<>(restCode.getCode(), restCode.getMessage(), data);
}
/**
* 失败响应(只返回状态码和消息,不带数据)
*
* @param restCode 状态码枚举
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> failure(RestCode restCode) {
return new RestBean<>(restCode.getCode(), restCode.getMessage(), null);
}
/**
* 失败响应(自定义 code 和 message
*
* @param code 状态码
* @param message 提示消息
* @param data 返回的数据
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> failure(int code, String message, V data) {
return new RestBean<>(code, message, data);
}
}

View File

@@ -0,0 +1,44 @@
package com.aisi.template.domain;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import lombok.ToString;
import java.util.HashMap;
import java.util.Map;
@Getter
@ToString
public enum RestCode {
SUCCESS(200,"操作成功"),
FAILURE(400,"操作失败"),
USERNAME_OR_PASSWORD_ERROR(402,"用户名或密码错误"),
NO_LOGIN(401,"用户未登录"),
UNAUTHORIZED(403,"未授权"),
TOKEN_EXPIRE(403, "token已过期"),
TOKEN_EMPTY(403, "token不能为空"),
TOKEN_INVALID(403, "token非法"),
SYSTEM_ERROR(500,"系统错误,请联系管理员" ),
DATA_NOT_FOUND(404,"数据不存在"),
DATA_ALREADY_FOUND(409,"数据已存在"),
METHOD_NOT_SUPPORT(405,"不支持该请求方法");
private final int code;
private final String message;
private final Map<String, Object> json; // 预先创建的不可变 Map
RestCode(Integer code, String message) {
this.code = code;
this.message = message;
HashMap<String, Object> map = new HashMap<>();
map.put("code", code);
map.put("message", message);
this.json = map;
}
@JsonValue
public Map<String,Object> toJson(){
return json;
}
}

View File

@@ -0,0 +1,59 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分页结果封装类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分页结果")
public class PageResult<T> {
@Schema(description = "数据列表")
private List<T> records;
@Schema(description = "总记录数")
private Long total;
@Schema(description = "当前页")
private Integer page;
@Schema(description = "每页大小")
private Integer size;
@Schema(description = "总页数")
private Integer pages;
/**
* 创建分页结果
*/
public static <T> PageResult<T> of(List<T> records, Long total, Integer page, Integer size) {
return new PageResult<>(
records,
total,
page,
size,
(int) Math.ceil((double) total / size)
);
}
/**
* 创建空的分页结果
*/
public static <T> PageResult<T> empty(Integer page, Integer size) {
return new PageResult<>(
List.of(),
0L,
page,
size,
0
);
}
}

View File

@@ -0,0 +1,28 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "重置密码确认请求")
public class PasswordResetConfirmDto {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱")
private String email;
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字")
@Schema(description = "6位验证码")
private String code;
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间")
@Schema(description = "新密码")
private String newPassword;
}

View File

@@ -0,0 +1,16 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "找回密码请求")
public class PasswordResetRequestDto {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱")
private String email;
}

View File

@@ -0,0 +1,24 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "用户注册/登录请求")
public class UserDto {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间")
@Schema(description = "用户名", example = "admin")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间")
@Schema(description = "密码", example = "123456")
private String password;
@Schema(description = "邮箱(注册时可选)", example = "user@example.com")
private String email;
}

View File

@@ -0,0 +1,24 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "用户查询参数")
public class UserQueryDto {
@Schema(description = "页码", example = "1")
private Integer page = 1;
@Schema(description = "每页大小", example = "10")
private Integer size = 10;
@Schema(description = "关键词(用户名/邮箱)")
private String keyword;
@Schema(description = "角色USER/ADMIN")
private String role;
@Schema(description = "状态1=正常 0=禁用)")
private Integer status;
}

View File

@@ -0,0 +1,14 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "用户角色更新请求")
public class UserRoleUpdateDto {
@NotBlank(message = "角色不能为空")
@Schema(description = "角色USER/ADMIN", example = "ADMIN")
private String role;
}

View File

@@ -0,0 +1,18 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "用户状态更新请求")
public class UserStatusUpdateDto {
@NotNull(message = "状态不能为空")
@Min(value = 0, message = "状态只能为 0 或 1")
@Max(value = 1, message = "状态只能为 0 或 1")
@Schema(description = "状态1=正常 0=禁用)", example = "1")
private Integer status;
}

View File

@@ -0,0 +1,47 @@
package com.aisi.template.domain.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "password_reset_codes", indexes = {
@Index(name = "idx_password_reset_email", columnList = "email"),
@Index(name = "idx_password_reset_expires_at", columnList = "expires_at")
})
@Data
public class PasswordResetCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String email;
@Column(name = "code_hash", nullable = false, length = 64)
private String codeHash;
@Column(name = "expires_at", nullable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime expiresAt;
@Column(name = "used", nullable = false)
private Boolean used = false;
@Column(name = "attempt_count", nullable = false)
private Integer attemptCount = 0;
@Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,59 @@
package com.aisi.template.domain.entity;
import com.aisi.template.domain.enums.Role;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.*;
import lombok.Data;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Data
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@ToString.Exclude
@JsonIgnore
@Column(nullable = false)
private String password;
@Column(unique = true)
private String email;
@Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'")
private Integer status = 1;
@Column(name = "role", nullable = false, length = 20, columnDefinition = "VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色USER=普通用户ADMIN=管理员'")
@Enumerated(EnumType.STRING)
private Role role = Role.USER;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt;
/**
* 检查用户是否启用
*/
public boolean isEnabled() {
return status != null && status == 1;
}
}

View File

@@ -0,0 +1,24 @@
package com.aisi.template.domain.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 通用错误
PARAM_ERROR(400, "参数错误"),
SYSTEM_ERROR(500, "系统繁忙,请稍后再试"),
// 用户相关 (1000 - 1999)
USER_NOT_FOUND(1001, "用户不存在"),
PASSWORD_ERROR(1002, "密码错误"),
USER_LOCKED(1003, "账号已被锁定"),
// 业务相关 (2000 - 2999) - 预留给具体业务模块
;
private final int code;
private final String message;
}

View File

@@ -0,0 +1,49 @@
package com.aisi.template.domain.enums;
import lombok.Getter;
/**
* 用户角色枚举
*/
@Getter
public enum Role {
/**
* 普通用户
*/
USER("ROLE_USER", "普通用户"),
/**
* 管理员
*/
ADMIN("ROLE_ADMIN", "管理员");
private final String authority;
private final String description;
Role(String authority, String description) {
this.authority = authority;
this.description = description;
}
/**
* 从数据库字符串转换为枚举
*/
public static Role fromString(String role) {
if (role == null) {
return USER;
}
try {
return Role.valueOf(role.toUpperCase());
} catch (IllegalArgumentException e) {
return USER;
}
}
/**
* 转换为数据库存储格式
*/
public String toDbValue() {
return this.name();
}
}

View File

@@ -0,0 +1,35 @@
package com.aisi.template.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "用户视图对象")
public class UserVo {
@Schema(description = "用户ID")
private Long id;
@Schema(description = "用户名")
private String username;
@Schema(description = "邮箱")
private String email;
@Schema(description = "状态1=正常 0=禁用)")
private Integer status;
@Schema(description = "角色USER=普通用户ADMIN=管理员)")
private String role;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,39 @@
package com.aisi.template.exception;
import com.aisi.template.domain.enums.ErrorCode;
import lombok.Getter;
/**
* 自定义业务异常
* 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息
*/
@Getter // 使用 Lombok 自动生成 getCode() 方法
public class BusinessException extends RuntimeException {
// 错误码 (例如 400, 403, 1001 等)
private final int code;
/**
* 构造方法 1手动指定 code 和 message
* 使用throw new BusinessException(404, "找不到该新闻");
*/
public BusinessException(int code, String message) {
super(message); // 把 message 传给父类,方便 log 打印
this.code = code;
}
/**
* 构造方法 2使用通用错误码 (默认为 400 或 500)
* 使用throw new BusinessException("操作失败");
*/
public BusinessException(String message) {
super(message);
this.code = 400; // 默认给个 400
}
// 在 BusinessException 类里添加这个构造方法
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
}

View File

@@ -0,0 +1,67 @@
package com.aisi.template.filter;
import com.aisi.template.domain.CustomUserDetails;
import com.aisi.template.service.impl.CustomUserDetailsService;
import com.aisi.template.utils.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtUtil.validateToken(token)) {
// token 有效
String username = jwtUtil.extractUsername(token);
if (username != null && !username.isEmpty()) {
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
if (userDetails == null) throw new UsernameNotFoundException("User not found");
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} else {
// token 缺失或无效时,不抛异常,直接放行到下一个过滤器
}
filterChain.doFilter(request, response);
}
/**
* 提取出token
*
* @return token
*/
private String extractToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization == null) {
return null;
}
if (!authorization.startsWith("Bearer ")) {
return null;
}
return authorization.substring(7);
}
}

View File

@@ -0,0 +1,156 @@
package com.aisi.template.handler;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.RestCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import com.aisi.template.exception.BusinessException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ==========================================
// 1. 客户端请求错误 (400 Bad Request 等)
// 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈
// ==========================================
/**
* 1. 参数校验失败异常 (@Valid / @Validated)
* 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求
*/
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public RestBean<String> handleValidationException(Exception e) {
BindingResult bindingResult = null;
if (e instanceof MethodArgumentNotValidException) {
bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
} else if (e instanceof BindException) {
bindingResult = ((BindException) e).getBindingResult();
}
// 提取具体的错误信息(例如:"email: 邮箱格式不正确"
String msg = "参数校验失败";
if (bindingResult != null) {
msg = bindingResult.getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
}
log.warn("参数校验未通过: {}", msg);
return RestBean.failure(RestCode.FAILURE, msg);
}
/**
* 2. JSON 格式解析错误
* 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public RestBean<String> handleJsonParseException(HttpMessageNotReadableException e) {
log.warn("JSON解析失败: {}", e.getMessage());
return RestBean.failure(RestCode.FAILURE, "请求Body格式错误请检查JSON语法");
}
/**
* 3. 缺少必要的 URL 参数
* 场景:接口定义了 @RequestParam(required=true) 但前端没传
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public RestBean<String> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("缺少请求参数: {}", e.getParameterName());
return RestBean.failure(RestCode.FAILURE, "缺少必要参数: " + e.getParameterName());
}
/**
* 4. 请求方法不支持
* 场景:接口只写了 @PostMapping前端却用 GET 请求访问
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public RestBean<String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("请求方法不支持: method={}, supported={}", e.getMethod(), e.getSupportedHttpMethods());
return RestBean.failure(RestCode.METHOD_NOT_SUPPORT, "不支持该请求方法: " + e.getMethod());
}
/**
* 5. 参数类型不匹配 (刚才你遇到的那个)
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public RestBean<String> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName());
log.warn("参数类型不匹配: {}", msg);
return RestBean.failure(RestCode.FAILURE, msg);
}
// ==========================================
// 2. 业务逻辑与数据库错误
// ==========================================
/**
* 6. 自定义业务异常 (最常用!)
* 场景:你在 Service 层手动抛出 throw new BusinessException(403, "权限不足");
*/
@ExceptionHandler(BusinessException.class)
public RestBean<String> handleBusinessException(BusinessException e) {
// 业务异常通常是预期内的,记录 WARN 即可
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return RestBean.failure(e.getCode(), e.getMessage(),"");
}
/**
* 7. 数据库唯一键冲突
* 场景:注册时用户名已存在,插入数据库时触发 Unique Constraint
*/
@ExceptionHandler(DuplicateKeyException.class)
public RestBean<String> handleDuplicateKeyException(DuplicateKeyException e) {
log.warn("数据库数据冲突: {}", e.getMessage());
return RestBean.failure(RestCode.DATA_ALREADY_FOUND, "数据已存在,请勿重复操作");
}
// ==========================================
// 3. 致命系统错误 (500)
// 这类错误是 Bug必须记录堆栈信息 (e),并报警
// ==========================================
/**
* 8. 空指针异常 (NullPointerException)
* 场景代码里没做判空a.b() 时 a 是 null
*/
@ExceptionHandler(NullPointerException.class)
public RestBean<String> handleNPE(NullPointerException e) {
// 必须打印堆栈!
log.error("发生空指针异常: ", e);
return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员");
}
/**
* 处理权限拒绝异常
*/
@ExceptionHandler(AuthorizationDeniedException.class)
public RestBean<Void> handleAuthorizationDenied(AuthorizationDeniedException e) {
log.warn("权限拒绝: {}", e.getMessage());
return RestBean.failure(RestCode.UNAUTHORIZED);
}
/**
* 处理其他异常
*/
@ExceptionHandler(Exception.class)
public RestBean<Void> handleException(Exception e) {
log.error("系统异常: ", e);
return RestBean.failure(RestCode.SYSTEM_ERROR);
}
}

View File

@@ -0,0 +1,61 @@
package com.aisi.template.interceptor;
import com.aisi.template.utils.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.UUID;
@Component
public class LoggingInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
String requestId = UUID.randomUUID().toString().substring(0, 8);
request.setAttribute("requestId", requestId);
Long userId = SecurityUtils.getUserId();
request.setAttribute("userId", userId);
String username = SecurityUtils.getUsername();
request.setAttribute("username", username);
if (isApiRequest(request.getRequestURI())) {
logger.info("[{}] API请求开始 -> method: {}, path: {}, userId: {}, username: {}",
requestId, request.getMethod(), request.getRequestURI(), userId, username);
}
return true; // ✅ 必须返回true才能继续执行请求
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
Long startTime = (Long) request.getAttribute("startTime");
if (startTime != null && isApiRequest(request.getRequestURI())) {
long duration = System.currentTimeMillis() - startTime;
String requestId = (String) request.getAttribute("requestId");
Long userId = (Long) request.getAttribute("userId");
String username = (String) request.getAttribute("username");
logger.info("[{}] API请求完成 -> method: {}, path: {}, status: {}, userId: {}, username: {}, duration: {}ms",
requestId, request.getMethod(), request.getRequestURI(), response.getStatus(), userId, username, duration);
if (ex != null) {
logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex);
}
}
}
private boolean isApiRequest(String path) {
return path.startsWith("/api/");
}
}

View File

@@ -0,0 +1,17 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.PasswordResetCode;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> {
List<PasswordResetCode> findByEmailAndUsedFalse(String email);
Optional<PasswordResetCode> findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email);
long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt);
}

View File

@@ -0,0 +1,42 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findByEmailIgnoreCase(String email);
Optional<User> findByUsernameAndPassword(String username, String password);
Optional<User> findByEmailAndPassword(String email, String password);
/**
* 根据用户名查找启用的用户
*/
Optional<User> findByUsernameAndStatus(String username, Integer status);
/**
* 根据邮箱查找启用的用户
*/
Optional<User> findByEmailAndStatus(String email, Integer status);
/**
* 检查用户名是否存在
*/
boolean existsByUsername(String username);
/**
* 检查邮箱是否存在
*/
boolean existsByEmail(String email);
boolean existsByEmailIgnoreCase(String email);
}

View File

@@ -0,0 +1,6 @@
package com.aisi.template.service;
public interface EmailService {
void sendPasswordResetCode(String email, String code);
}

View File

@@ -0,0 +1,12 @@
package com.aisi.template.service;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.dto.PasswordResetConfirmDto;
import com.aisi.template.domain.dto.PasswordResetRequestDto;
public interface PasswordResetService {
RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto);
RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto);
}

View File

@@ -0,0 +1,24 @@
package com.aisi.template.service;
import com.aisi.template.domain.RestBean;
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.vo.UserVo;
public interface UserService {
RestBean<UserVo> getUserInfo();
RestBean<String> register(UserDto userDto);
RestBean<String> login(UserDto userDto);
RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto);
RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto);
RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto);
}

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

View File

@@ -0,0 +1,57 @@
package com.aisi.template.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
private final long expiration = 1000 * 60 * 60 *24; // 24小时
private Key getSigningKey() {
// HS512 要求密钥至少 512 位 = 64 字节
if (secret.length() < 64) {
throw new IllegalArgumentException("JWT secret must be at least 64 characters long for HS512");
}
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Long userId,String username) {
return Jwts.builder()
.setSubject(username)
.claim("id", userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getBody().getSubject();
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
private Jws<Claims> parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
}
}

View File

@@ -0,0 +1,337 @@
package com.aisi.template.utils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class RedisUtils {
private final RedisTemplate<String, Object> redisTemplate;
// ======================== 通用 ========================
public Boolean delete(String key) {
return redisTemplate.delete(key);
}
public Long delete(Collection<String> keys) {
return redisTemplate.delete(keys);
}
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
public Boolean expire(String key, long timeout, TimeUnit unit) {
return redisTemplate.expire(key, timeout, unit);
}
public Long getExpire(String key) {
return redisTemplate.getExpire(key);
}
public Boolean expireAt(String key, long timestamp) {
return redisTemplate.expireAt(key, new java.util.Date(timestamp));
}
public Set<String> keys(String pattern) {
return redisTemplate.keys(pattern);
}
public Boolean rename(String oldKey, String newKey) {
redisTemplate.rename(oldKey, newKey);
return true;
}
public Long ttl(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
// ======================== String ========================
public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
public void setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public <T> T get(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
return null;
}
return clazz.cast(value);
}
public String getAndSet(String key, Object value) {
Object old = redisTemplate.opsForValue().getAndSet(key, value);
return old != null ? old.toString() : null;
}
public Long increment(String key) {
return redisTemplate.opsForValue().increment(key);
}
public Long increment(String key, long delta) {
return redisTemplate.opsForValue().increment(key, delta);
}
public Long decrement(String key) {
return redisTemplate.opsForValue().decrement(key);
}
public Long decrement(String key, long delta) {
return redisTemplate.opsForValue().decrement(key, delta);
}
public Long strLength(String key) {
return redisTemplate.opsForValue().size(key);
}
// ======================== Hash ========================
public void hSet(String key, String hashKey, Object value) {
redisTemplate.opsForHash().put(key, hashKey, value);
}
public void hSetAll(String key, Map<String, Object> map) {
redisTemplate.opsForHash().putAll(key, map);
}
public Object hGet(String key, String hashKey) {
return redisTemplate.opsForHash().get(key, hashKey);
}
public Map<Object, Object> hGetAll(String key) {
return redisTemplate.opsForHash().entries(key);
}
public void hDelete(String key, Object... hashKeys) {
redisTemplate.opsForHash().delete(key, hashKeys);
}
public Boolean hHasKey(String key, String hashKey) {
return redisTemplate.opsForHash().hasKey(key, hashKey);
}
public Long hSize(String key) {
return redisTemplate.opsForHash().size(key);
}
public Long hIncrement(String key, String hashKey, long delta) {
return redisTemplate.opsForHash().increment(key, hashKey, delta);
}
public Set<Object> hKeys(String key) {
return redisTemplate.opsForHash().keys(key);
}
public List<Object> hValues(String key) {
return redisTemplate.opsForHash().values(key);
}
// ======================== List ========================
public Long lPush(String key, Object value) {
return redisTemplate.opsForList().leftPush(key, value);
}
public Long lPushAll(String key, Object... values) {
return redisTemplate.opsForList().leftPushAll(key, values);
}
public Long rPush(String key, Object value) {
return redisTemplate.opsForList().rightPush(key, value);
}
public Long rPushAll(String key, Object... values) {
return redisTemplate.opsForList().rightPushAll(key, values);
}
public Object lPop(String key) {
return redisTemplate.opsForList().leftPop(key);
}
public List<Object> lPop(String key, long count) {
return redisTemplate.opsForList().leftPop(key, count);
}
public Object rPop(String key) {
return redisTemplate.opsForList().rightPop(key);
}
public List<Object> rPop(String key, long count) {
return redisTemplate.opsForList().rightPop(key, count);
}
public Object lIndex(String key, long index) {
return redisTemplate.opsForList().index(key, index);
}
public Long lSize(String key) {
return redisTemplate.opsForList().size(key);
}
public List<Object> lRange(String key, long start, long end) {
return redisTemplate.opsForList().range(key, start, end);
}
public void lTrim(String key, long start, long end) {
redisTemplate.opsForList().trim(key, start, end);
}
public void lSet(String key, long index, Object value) {
redisTemplate.opsForList().set(key, index, value);
}
public Long lRemove(String key, long count, Object value) {
return redisTemplate.opsForList().remove(key, count, value);
}
// ======================== Set ========================
public Long sAdd(String key, Object... values) {
return redisTemplate.opsForSet().add(key, values);
}
public Long sRemove(String key, Object... values) {
return redisTemplate.opsForSet().remove(key, values);
}
public Set<Object> sMembers(String key) {
return redisTemplate.opsForSet().members(key);
}
public Boolean sIsMember(String key, Object value) {
return redisTemplate.opsForSet().isMember(key, value);
}
public Long sSize(String key) {
return redisTemplate.opsForSet().size(key);
}
public Object sRandomMember(String key) {
return redisTemplate.opsForSet().randomMember(key);
}
public Set<Object> sRandomMembers(String key, long count) {
return redisTemplate.opsForSet().distinctRandomMembers(key, count);
}
public Set<Object> sIntersect(String key1, String key2) {
return redisTemplate.opsForSet().intersect(key1, key2);
}
public Set<Object> sUnion(String key1, String key2) {
return redisTemplate.opsForSet().union(key1, key2);
}
public Set<Object> sDifference(String key1, String key2) {
return redisTemplate.opsForSet().difference(key1, key2);
}
// ======================== ZSet有序集合========================
public Boolean zAdd(String key, Object value, double score) {
return redisTemplate.opsForZSet().add(key, value, score);
}
public Long zAdd(String key, Set<ZSetOperations.TypedTuple<Object>> tuples) {
return redisTemplate.opsForZSet().add(key, tuples);
}
public Long zRemove(String key, Object... values) {
return redisTemplate.opsForZSet().remove(key, values);
}
public Long zRank(String key, Object value) {
return redisTemplate.opsForZSet().rank(key, value);
}
public Long zReverseRank(String key, Object value) {
return redisTemplate.opsForZSet().reverseRank(key, value);
}
public Set<Object> zRange(String key, long start, long end) {
return redisTemplate.opsForZSet().range(key, start, end);
}
public Set<Object> zReverseRange(String key, long start, long end) {
return redisTemplate.opsForZSet().reverseRange(key, start, end);
}
public Set<ZSetOperations.TypedTuple<Object>> zRangeWithScores(String key, long start, long end) {
return redisTemplate.opsForZSet().rangeWithScores(key, start, end);
}
public Set<Object> zRangeByScore(String key, double min, double max) {
return redisTemplate.opsForZSet().rangeByScore(key, min, max);
}
public Set<Object> zRangeByScore(String key, double min, double max, long offset, long count) {
return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count);
}
public Long zCount(String key, double min, double max) {
return redisTemplate.opsForZSet().count(key, min, max);
}
public Long zSize(String key) {
return redisTemplate.opsForZSet().size(key);
}
public Double zScore(String key, Object value) {
return redisTemplate.opsForZSet().score(key, value);
}
public Long zRemoveRange(String key, long start, long end) {
return redisTemplate.opsForZSet().removeRange(key, start, end);
}
public Long zRemoveRangeByScore(String key, double min, double max) {
return redisTemplate.opsForZSet().removeRangeByScore(key, min, max);
}
// ======================== Bitmap ========================
public Boolean setBit(String key, long offset, boolean value) {
return redisTemplate.opsForValue().setBit(key, offset, value);
}
public Boolean getBit(String key, long offset) {
return redisTemplate.opsForValue().getBit(key, offset);
}
// ======================== HyperLogLog ========================
public Long pfAdd(String key, Object... values) {
return redisTemplate.opsForHyperLogLog().add(key, values);
}
public Long pfCount(String... keys) {
return redisTemplate.opsForHyperLogLog().size(keys);
}
public void pfMerge(String destination, String... sourceKeys) {
redisTemplate.opsForHyperLogLog().union(destination, sourceKeys);
}
}

View File

@@ -0,0 +1,46 @@
package com.aisi.template.utils;
import com.aisi.template.domain.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
public class SecurityUtils {
/**
* 获取当前 Authentication
*/
public static Authentication getAuthentication(){
return SecurityContextHolder.getContext().getAuthentication();
}
/**
* 获取登录名
*/
public static String getUsername(){
Authentication auth = getAuthentication();
if (auth != null && auth.isAuthenticated()) {
Object principal = auth.getPrincipal();
if (principal instanceof CustomUserDetails user) {
return user.getUsername();
}else if (principal instanceof String username) {
return username;
}
}
return null;
}
/**
* 获取用户Id
*/
public static Long getUserId() {
Authentication auth = getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomUserDetails user) {
return user.getId();
}
return null;
}
}

View File

@@ -0,0 +1,58 @@
spring:
output:
ansi:
enabled: always
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:template}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: ${DB_USER:root}
password: ${DB_PASS:root}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: true
database-platform: org.hibernate.dialect.MySQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-wait: -1ms
mail:
host: ${MAIL_HOST:smtp.163.com}
port: ${MAIL_PORT:465}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
protocol: ${MAIL_PROTOCOL:smtps}
properties:
mail:
smtp:
auth: true
ssl:
enable: true
jwt:
secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!}
app:
password-reset:
code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10}
request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60}
max-attempts: ${PASSWORD_RESET_MAX_ATTEMPTS:5}
logging:
level:
org.hibernate.SQL: warn
org.hibernate.orm.jdbc.bind: warn
org.hibernate.type.descriptor.sql.BasicBinder: warn

View File

@@ -0,0 +1,46 @@
spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:template}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: ${DB_USER:root}
password: ${DB_PASS:root}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
database-platform: org.hibernate.dialect.MySQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0}
timeout: 5000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: -1ms
mail:
host: ${MAIL_HOST:smtp.163.com}
port: ${MAIL_PORT:465}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
protocol: ${MAIL_PROTOCOL:smtps}
properties:
mail:
smtp:
auth: true
ssl:
enable: true
jwt:
secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!}
app:
password-reset:
code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10}
request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60}
max-attempts: ${PASSWORD_RESET_MAX_ATTEMPTS:5}

View File

@@ -0,0 +1,10 @@
spring:
application:
name: springboot-template
profiles:
active: dev
config:
import: optional:file:.env[.properties]
server:
port: 8080

View File

@@ -0,0 +1,35 @@
-- ============================================
-- Template database initialization script
-- ============================================
CREATE TABLE IF NOT EXISTS users (
`id` bigint NOT NULL AUTO_INCREMENT,
`email` varchar(255) DEFAULT NULL,
`password` varchar(255) NOT NULL,
`username` varchar(50) NOT NULL,
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=enabled 0=disabled',
`role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'Role: USER or ADMIN',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY uk_users_username (`username`),
UNIQUE KEY uk_users_email (`email`),
KEY idx_users_role (`role`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS password_reset_codes (
`id` bigint NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`code_hash` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL,
`used` bit(1) NOT NULL DEFAULT b'0',
`attempt_count` int NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_password_reset_email` (`email`),
KEY `idx_password_reset_expires_at` (`expires_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -0,0 +1,12 @@
package com.aisi.template;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class TemplateApplicationTests {
@Test
void contextLoads() {
}
}