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:
13
src/main/java/com/aisi/template/TemplateApplication.java
Normal file
13
src/main/java/com/aisi/template/TemplateApplication.java
Normal 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");
|
||||
}
|
||||
}
|
||||
34
src/main/java/com/aisi/template/config/JacksonConfig.java
Normal file
34
src/main/java/com/aisi/template/config/JacksonConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/main/java/com/aisi/template/config/JpaConfig.java
Normal file
12
src/main/java/com/aisi/template/config/JpaConfig.java
Normal 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 {
|
||||
}
|
||||
15
src/main/java/com/aisi/template/config/OpenApiConfig.java
Normal file
15
src/main/java/com/aisi/template/config/OpenApiConfig.java
Normal 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 {
|
||||
}
|
||||
48
src/main/java/com/aisi/template/config/RedisConfig.java
Normal file
48
src/main/java/com/aisi/template/config/RedisConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
66
src/main/java/com/aisi/template/config/SecurityConfig.java
Normal file
66
src/main/java/com/aisi/template/config/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
22
src/main/java/com/aisi/template/config/WebConfig.java
Normal file
22
src/main/java/com/aisi/template/config/WebConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
118
src/main/java/com/aisi/template/domain/RestBean.java
Normal file
118
src/main/java/com/aisi/template/domain/RestBean.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
44
src/main/java/com/aisi/template/domain/RestCode.java
Normal file
44
src/main/java/com/aisi/template/domain/RestCode.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
59
src/main/java/com/aisi/template/domain/dto/PageResult.java
Normal file
59
src/main/java/com/aisi/template/domain/dto/PageResult.java
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
24
src/main/java/com/aisi/template/domain/dto/UserDto.java
Normal file
24
src/main/java/com/aisi/template/domain/dto/UserDto.java
Normal 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;
|
||||
}
|
||||
24
src/main/java/com/aisi/template/domain/dto/UserQueryDto.java
Normal file
24
src/main/java/com/aisi/template/domain/dto/UserQueryDto.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/main/java/com/aisi/template/domain/entity/User.java
Normal file
59
src/main/java/com/aisi/template/domain/entity/User.java
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/aisi/template/domain/enums/ErrorCode.java
Normal file
24
src/main/java/com/aisi/template/domain/enums/ErrorCode.java
Normal 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;
|
||||
}
|
||||
49
src/main/java/com/aisi/template/domain/enums/Role.java
Normal file
49
src/main/java/com/aisi/template/domain/enums/Role.java
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/main/java/com/aisi/template/domain/vo/UserVo.java
Normal file
35
src/main/java/com/aisi/template/domain/vo/UserVo.java
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
public interface EmailService {
|
||||
|
||||
void sendPasswordResetCode(String email, String code);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
24
src/main/java/com/aisi/template/service/UserService.java
Normal file
24
src/main/java/com/aisi/template/service/UserService.java
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
src/main/java/com/aisi/template/utils/JwtUtil.java
Normal file
57
src/main/java/com/aisi/template/utils/JwtUtil.java
Normal 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);
|
||||
}
|
||||
}
|
||||
337
src/main/java/com/aisi/template/utils/RedisUtils.java
Normal file
337
src/main/java/com/aisi/template/utils/RedisUtils.java
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/main/java/com/aisi/template/utils/SecurityUtils.java
Normal file
46
src/main/java/com/aisi/template/utils/SecurityUtils.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
58
src/main/resources/application-dev.yaml
Normal file
58
src/main/resources/application-dev.yaml
Normal 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
|
||||
46
src/main/resources/application-prod.yaml
Normal file
46
src/main/resources/application-prod.yaml
Normal 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}
|
||||
10
src/main/resources/application.yaml
Normal file
10
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: springboot-template
|
||||
profiles:
|
||||
active: dev
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
35
src/main/resources/sql/init.sql
Normal file
35
src/main/resources/sql/init.sql
Normal 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;
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user