Initial commit

This commit is contained in:
2025-11-05 23:30:19 +08:00
commit 1910732965
107 changed files with 10748 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Git
.git
.gitignore
.gitattributes
# CI/CD
.github
# IDE
.idea
.vscode
*.iml
*.iws
*.ipr
# Maven
target/
!target/*.jar
!.mvn/wrapper/maven-wrapper.jar
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
docs/
# Docker
Dockerfile
.dockerignore
docker-compose.yml
+55
View File
@@ -0,0 +1,55 @@
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# Eclipse
.settings/
.project
.classpath
.factorypath
# IntelliJ IDEA
.idea/
*.iws
*.iml
*.ipr
out/
# VS Code
.vscode/
# macOS
.DS_Store
# Windows
Thumbs.db
desktop.ini
# Log files
*.log
logs/
# Temporary files
*.tmp
*.temp
*.swp
*~
# Application properties with sensitive data
application-local.yml
application-local.properties
# Maven cache (CI/CD)
.m2/
# Environment variables
.env
.env.local
+12
View File
@@ -0,0 +1,12 @@
# 多阶段构建 Dockerfile
# 阶段1: 构建
FROM openjdk:21
WORKDIR /app
COPY target/auth-backend-1.0.0.jar /app/
EXPOSE 9001
# 启动应用
CMD ["java", "-jar", "auth-backend-1.0.0.jar"]
+173
View File
@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/>
</parent>
<groupId>com.kurihada</groupId>
<artifactId>auth-backend</artifactId>
<version>1.0.0</version>
<name>auth-system</name>
<description>权限管理系统</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jjwt.version>0.12.3</jjwt.version>
<mybatis-plus.version>3.5.14</mybatis-plus.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>${mybatis-plus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Boot Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Thymeleaf (用于邮件模板) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Lettuce (Redis客户端) -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Knife4j (Swagger UI Enhancement) -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.3.0</version>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Security Test -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package com.kurihada.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 权限系统主应用类
*/
@SpringBootApplication
public class AuthSystemApplication {
public static void main(String[] args) {
SpringApplication.run(AuthSystemApplication.class, args);
}
}
@@ -0,0 +1,66 @@
package com.kurihada.auth.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 限流注解
* 用于标记需要限流的接口
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/**
* 限流的键前缀,用于区分不同的限流规则
* 例如: "login", "register", "api"
*/
String key() default "rate_limit";
/**
* 时间窗口,单位:秒
* 默认 60 秒
*/
int window() default 60;
/**
* 时间窗口内允许的最大请求次数
* 默认 5 次
*/
int maxRequests() default 5;
/**
* 限流维度类型
* IP: 基于IP地址限流
* USER: 基于用户名限流
* IP_AND_USER: 同时基于IP和用户名限流
*/
LimitType limitType() default LimitType.IP;
/**
* 限流提示消息
*/
String message() default "请求过于频繁,请稍后再试";
/**
* 限流维度类型枚举
*/
enum LimitType {
/**
* 基于IP地址限流
*/
IP,
/**
* 基于用户名限流
*/
USER,
/**
* 同时基于IP和用户名限流
*/
IP_AND_USER
}
}
@@ -0,0 +1,78 @@
package com.kurihada.auth.annotation;
import com.kurihada.auth.validation.PasswordValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 密码复杂度验证注解
* 验证密码是否满足复杂度要求
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
@Documented
public @interface ValidPassword {
/**
* 默认错误消息
*/
String message() default "密码不符合复杂度要求";
/**
* 分组
*/
Class<?>[] groups() default {};
/**
* 载荷
*/
Class<? extends Payload>[] payload() default {};
/**
* 最小长度(默认8位)
*/
int minLength() default 8;
/**
* 最大长度(默认32位)
*/
int maxLength() default 32;
/**
* 是否要求包含小写字母(默认true)
*/
boolean requireLowercase() default true;
/**
* 是否要求包含大写字母(默认true)
*/
boolean requireUppercase() default true;
/**
* 是否要求包含数字(默认true)
*/
boolean requireDigit() default true;
/**
* 是否要求包含特殊字符(默认true)
*/
boolean requireSpecialChar() default true;
/**
* 允许的特殊字符(默认常用特殊字符)
*/
String specialChars() default "!@#$%^&*()_+-=[]{}|;:,.<>?";
/**
* 是否禁止包含用户名(默认true)
*/
boolean forbidUsername() default true;
/**
* 是否禁止常见弱密码(默认true)
*/
boolean forbidCommonPasswords() default true;
}
@@ -0,0 +1,150 @@
package com.kurihada.auth.aspect;
import com.kurihada.auth.annotation.RateLimit;
import com.kurihada.auth.component.RedisRateLimiter;
import com.kurihada.auth.exception.RateLimitException;
import com.kurihada.auth.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
/**
* 限流切面
* 拦截带有 @RateLimit 注解的方法,执行限流检查
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final RedisRateLimiter rateLimiter;
@Around("@annotation(com.kurihada.auth.annotation.RateLimit)")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit == null) {
return point.proceed();
}
// 获取请求信息
HttpServletRequest request = getRequest();
if (request == null) {
log.warn("无法获取 HttpServletRequest,跳过限流检查");
return point.proceed();
}
// 构建限流键
String limitKey = buildLimitKey(rateLimit, request, point.getArgs());
// 执行限流检查
RedisRateLimiter.RateLimitResult result = rateLimiter.tryAcquire(
limitKey,
rateLimit.window(),
rateLimit.maxRequests()
);
if (!result.isAllowed()) {
String errorMessage = String.format("%s,请在 %d 秒后重试",
rateLimit.message(), result.getWaitTime());
log.warn("限流触发 - 方法: {}, Key: {}, 等待时间: {}秒",
method.getName(), limitKey, result.getWaitTime());
throw new RateLimitException(errorMessage, result.getWaitTime());
}
log.debug("限流检查通过 - 方法: {}, Key: {}, 剩余次数: {}",
method.getName(), limitKey, result.getRemaining());
return point.proceed();
}
/**
* 构建限流键
*/
private String buildLimitKey(RateLimit rateLimit, HttpServletRequest request, Object[] args) {
StringBuilder keyBuilder = new StringBuilder("rate_limit:");
keyBuilder.append(rateLimit.key()).append(":");
switch (rateLimit.limitType()) {
case IP:
String ip = IpUtil.getIpAddress(request);
keyBuilder.append("ip:").append(ip);
break;
case USER:
String username = extractUsername(args);
if (username != null) {
keyBuilder.append("user:").append(username);
} else {
// 如果无法获取用户名,降级为IP限流
log.warn("无法提取用户名,降级为IP限流");
String fallbackIp = IpUtil.getIpAddress(request);
keyBuilder.append("ip:").append(fallbackIp);
}
break;
case IP_AND_USER:
String ipAddr = IpUtil.getIpAddress(request);
String user = extractUsername(args);
if (user != null) {
keyBuilder.append("ip_user:").append(ipAddr).append(":").append(user);
} else {
// 如果无法获取用户名,只使用IP
keyBuilder.append("ip:").append(ipAddr);
}
break;
}
return keyBuilder.toString();
}
/**
* 从方法参数中提取用户名
* 支持 LoginRequest、RegisterRequest 等包含 username 字段的对象
*/
private String extractUsername(Object[] args) {
if (args == null || args.length == 0) {
return null;
}
for (Object arg : args) {
if (arg == null) {
continue;
}
try {
// 尝试通过反射获取 username 字段
Method getUsername = arg.getClass().getMethod("getUsername");
Object username = getUsername.invoke(arg);
if (username instanceof String) {
return (String) username;
}
} catch (Exception e) {
// 忽略异常,继续尝试下一个参数
}
}
return null;
}
/**
* 获取当前 HTTP 请求
*/
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
}
@@ -0,0 +1,168 @@
package com.kurihada.auth.component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
/**
* Redis 实现的限流器
* 使用滑动窗口算法
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisRateLimiter {
private final RedisTemplate<String, String> redisTemplate;
/**
* 滑动窗口限流算法的 Lua 脚本
* 使用 Sorted Set 实现,score 为时间戳
*/
private static final String RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local window = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local currentTime = tonumber(ARGV[3])
-- 移除窗口之外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - window * 1000)
-- 获取当前窗口内的请求数
local currentRequests = redis.call('ZCARD', key)
if currentRequests < maxRequests then
-- 允许请求,添加当前时间戳
redis.call('ZADD', key, currentTime, currentTime)
-- 设置过期时间(窗口时间 + 1秒缓冲)
redis.call('EXPIRE', key, window + 1)
return {1, maxRequests - currentRequests - 1}
else
-- 拒绝请求,返回最早请求的时间,用于计算需要等待的时间
local oldestTime = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')[2]
local remainingTime = math.ceil((tonumber(oldestTime) + window * 1000 - currentTime) / 1000)
return {0, remainingTime}
end
""";
/**
* 检查是否允许请求
*
* @param key 限流键
* @param window 时间窗口(秒)
* @param maxRequests 最大请求数
* @return 如果允许请求返回 true,否则返回 false
*/
public boolean isAllowed(String key, int window, int maxRequests) {
RateLimitResult result = tryAcquire(key, window, maxRequests);
return result.isAllowed();
}
/**
* 尝试获取请求许可
*
* @param key 限流键
* @param window 时间窗口(秒)
* @param maxRequests 最大请求数
* @return 限流结果
*/
public RateLimitResult tryAcquire(String key, int window, int maxRequests) {
try {
long currentTime = Instant.now().toEpochMilli();
RedisScript<List> script = RedisScript.of(RATE_LIMIT_SCRIPT, List.class);
List<Long> result = (List<Long>) redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(window),
String.valueOf(maxRequests),
String.valueOf(currentTime)
);
if (result == null || result.size() != 2) {
log.error("限流脚本执行异常,返回结果为空或格式错误");
// 降级处理:允许请求通过
return new RateLimitResult(true, 0, 0);
}
boolean allowed = result.get(0) == 1;
int remaining = result.get(1).intValue();
if (allowed) {
log.debug("限流检查通过,key: {}, 剩余请求数: {}", key, remaining);
return new RateLimitResult(true, remaining, 0);
} else {
log.warn("触发限流,key: {}, 需等待: {}秒", key, remaining);
return new RateLimitResult(false, 0, remaining);
}
} catch (Exception e) {
log.error("限流检查异常,key: {}", key, e);
// 降级处理:允许请求通过,避免因限流组件故障影响正常业务
return new RateLimitResult(true, 0, 0);
}
}
/**
* 重置限流计数
*
* @param key 限流键
*/
public void reset(String key) {
try {
redisTemplate.delete(key);
log.info("重置限流计数,key: {}", key);
} catch (Exception e) {
log.error("重置限流计数失败,key: {}", key, e);
}
}
/**
* 获取当前窗口内的请求数
*
* @param key 限流键
* @return 请求数
*/
public long getCurrentRequests(String key) {
try {
Long count = redisTemplate.opsForZSet().zCard(key);
return count != null ? count : 0;
} catch (Exception e) {
log.error("获取当前请求数失败,key: {}", key, e);
return 0;
}
}
/**
* 限流结果
*/
public static class RateLimitResult {
private final boolean allowed;
private final int remaining;
private final int waitTime;
public RateLimitResult(boolean allowed, int remaining, int waitTime) {
this.allowed = allowed;
this.remaining = remaining;
this.waitTime = waitTime;
}
public boolean isAllowed() {
return allowed;
}
public int getRemaining() {
return remaining;
}
public int getWaitTime() {
return waitTime;
}
}
}
@@ -0,0 +1,30 @@
package com.kurihada.auth.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 异步配置
* 用于审计日志等异步任务
*/
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-audit-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
@@ -0,0 +1,45 @@
package com.kurihada.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
* 开发环境跨域配置(允许所有域名)
*/
@Configuration
@Profile({"default", "dev"}) // 仅在开发环境生效
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 允许所有域名跨域(生产环境应该指定具体域名)
config.addAllowedOriginPattern("*");
// 允许所有请求头
config.addAllowedHeader("*");
// 允许所有请求方法
config.addAllowedMethod("*");
// 允许发送Cookie
config.setAllowCredentials(true);
// 预检请求的有效期,单位为秒
config.setMaxAge(3600L);
// 允许的响应头
config.addExposedHeader("Authorization");
config.addExposedHeader("Content-Type");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
@@ -0,0 +1,41 @@
package com.kurihada.auth.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Knife4j API文档配置
*/
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("权限管理系统 API")
.description("基于Spring Boot 3 + MyBatis Plus + JWT的权限管理系统接口文档")
.version("1.0.0")
.contact(new Contact()
.name("Auth System")
.email("support@example.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0")))
.components(new Components()
.addSecuritySchemes("Bearer",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("请输入JWT Token,格式:Bearer {token}")))
.addSecurityItem(new SecurityRequirement().addList("Bearer"));
}
}
@@ -0,0 +1,57 @@
package com.kurihada.auth.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.kurihada.auth.context.TenantContext;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.kurihada.auth.mapper")
public class MybatisPlusConfig {
/**
* 添加分页插件和租户插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 租户插件(必须在分页插件之前)
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从上下文中获取租户ID
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) {
return null;
}
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
// 租户字段名
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 忽略的表(不进行租户隔离)
// tenants 表本身不需要租户隔离
// permissions 表允许为 null(公共权限)
return "tenants".equalsIgnoreCase(tableName);
}
}));
// 分页插件(必须在租户插件之后)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
@@ -0,0 +1,64 @@
package com.kurihada.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
/**
* 生产环境跨域配置(限制允许的域名)
*/
@Configuration
@Profile("prod") // 仅在生产环境生效
public class ProdCorsConfig {
@Bean
public CorsFilter prodCorsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 生产环境:只允许指定的域名跨域
config.setAllowedOrigins(Arrays.asList(
"https://your-frontend-domain.com",
"https://www.your-frontend-domain.com",
"https://admin.your-frontend-domain.com"
));
// 允许的请求头
config.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"Accept",
"X-Requested-With"
));
// 允许的请求方法
config.setAllowedMethods(Arrays.asList(
"GET",
"POST",
"PUT",
"DELETE",
"OPTIONS"
));
// 允许发送Cookie
config.setAllowCredentials(true);
// 预检请求的有效期
config.setMaxAge(3600L);
// 允许的响应头
config.setExposedHeaders(Arrays.asList(
"Authorization",
"Content-Type"
));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
@@ -0,0 +1,49 @@
package com.kurihada.auth.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
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.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置类
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringSerializer);
// value序列化方式采用jackson
template.setValueSerializer(serializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
@@ -0,0 +1,102 @@
package com.kurihada.auth.config;
import com.kurihada.auth.security.JwtAccessDeniedHandler;
import com.kurihada.auth.security.JwtAuthenticationEntryPoint;
import com.kurihada.auth.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
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;
/**
* Spring Security配置类
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
/**
* 配置安全过滤器链
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF
.csrf(AbstractHttpConfigurer::disable)
// 启用CORS(使用默认配置,会自动使用CorsFilter)
.cors(cors -> {})
// 配置异常处理
.exceptionHandling(exception -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
// 配置会话管理 - 无状态
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 配置授权规则
.authorizeHttpRequests(auth -> auth
// 允许匿名访问的接口
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/register",
"/api/v1/auth/forgot-password", "/api/v1/auth/reset-password",
"/api/v1/auth/verify-email", "/api/v1/auth/resend-verification").permitAll()
// Knife4j 文档接口
.requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
// 其他接口需要认证
.anyRequest().authenticated()
)
// 配置认证提供者
.authenticationProvider(authenticationProvider())
// 添加JWT过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证提供者
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* 认证管理器
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,31 @@
package com.kurihada.auth.config;
import com.kurihada.auth.interceptor.TenantInterceptor;
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;
/**
* Web MVC 配置
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final TenantInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/doc.html",
"/swagger-resources/**",
"/webjars/**",
"/v3/api-docs/**"
);
}
}
@@ -0,0 +1,92 @@
package com.kurihada.auth.constant;
/**
* 缓存常量
*/
public class CacheConstants {
/**
* 缓存键前缀
*/
public static final String CACHE_PREFIX = "auth:";
/**
* 用户权限缓存键前缀
* 格式: auth:user:permissions:{userId}:{tenantId}
*/
public static final String USER_PERMISSIONS_KEY = CACHE_PREFIX + "user:permissions:";
/**
* 用户角色缓存键前缀
* 格式: auth:user:roles:{userId}:{tenantId}
*/
public static final String USER_ROLES_KEY = CACHE_PREFIX + "user:roles:";
/**
* 角色权限缓存键前缀
* 格式: auth:role:permissions:{roleId}:{tenantId}
*/
public static final String ROLE_PERMISSIONS_KEY = CACHE_PREFIX + "role:permissions:";
/**
* 租户所有用户权限缓存键前缀
* 格式: auth:tenant:users:{tenantId}
*/
public static final String TENANT_USERS_KEY = CACHE_PREFIX + "tenant:users:";
/**
* 用户信息缓存键前缀
* 格式: auth:user:info:{userId}
*/
public static final String USER_INFO_KEY = CACHE_PREFIX + "user:info:";
/**
* 权限缓存过期时间(秒)- 默认2小时
*/
public static final long PERMISSION_CACHE_TTL = 7200L;
/**
* 用户信息缓存过期时间(秒)- 默认30分钟
*/
public static final long USER_INFO_CACHE_TTL = 1800L;
/**
* 角色权限缓存过期时间(秒)- 默认1小时
*/
public static final long ROLE_PERMISSION_CACHE_TTL = 3600L;
/**
* 构建用户权限缓存键
*/
public static String buildUserPermissionKey(Long userId, Long tenantId) {
return USER_PERMISSIONS_KEY + userId + ":" + tenantId;
}
/**
* 构建用户角色缓存键
*/
public static String buildUserRoleKey(Long userId, Long tenantId) {
return USER_ROLES_KEY + userId + ":" + tenantId;
}
/**
* 构建角色权限缓存键
*/
public static String buildRolePermissionKey(Long roleId, Long tenantId) {
return ROLE_PERMISSIONS_KEY + roleId + ":" + tenantId;
}
/**
* 构建租户用户集合缓存键
*/
public static String buildTenantUsersKey(Long tenantId) {
return TENANT_USERS_KEY + tenantId;
}
/**
* 构建用户信息缓存键
*/
public static String buildUserInfoKey(Long userId) {
return USER_INFO_KEY + userId;
}
}
@@ -0,0 +1,30 @@
package com.kurihada.auth.context;
/**
* 租户上下文 - 使用ThreadLocal存储当前租户ID
*/
public class TenantContext {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
/**
* 设置当前租户ID
*/
public static void setTenantId(Long tenantId) {
CURRENT_TENANT.set(tenantId);
}
/**
* 获取当前租户ID
*/
public static Long getTenantId() {
return CURRENT_TENANT.get();
}
/**
* 清除当前租户ID
*/
public static void clear() {
CURRENT_TENANT.remove();
}
}
@@ -0,0 +1,58 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.dto.AuditLogQueryRequest;
import com.kurihada.auth.dto.AuditLogResponse;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.Result;
import com.kurihada.auth.service.AuditLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
/**
* 审计日志控制器
*/
@Tag(name = "审计日志管理", description = "审计日志查询相关接口")
@RestController
@RequestMapping("/api/v1/audit-logs")
@RequiredArgsConstructor
@SecurityRequirement(name = "Bearer")
public class AuditLogController {
private final AuditLogService auditLogService;
/**
* 分页查询审计日志
*/
@Operation(
summary = "分页查询审计日志",
description = "根据条件查询审计日志,支持按用户、操作类型、时间范围等筛选。需要 ADMIN 或 AUDIT_VIEWER 权限"
)
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'AUDIT_VIEWER')")
public Result<PageResult<AuditLogResponse>> queryAuditLogs(
@Valid @ModelAttribute AuditLogQueryRequest request) {
PageResult<AuditLogResponse> result = auditLogService.queryAuditLogs(request);
return Result.success(result);
}
/**
* 根据ID获取审计日志详情
*/
@Operation(
summary = "获取审计日志详情",
description = "根据日志ID查询审计日志详细信息。需要 ADMIN 或 AUDIT_VIEWER 权限"
)
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'AUDIT_VIEWER')")
public Result<AuditLogResponse> getAuditLogById(
@Parameter(description = "审计日志ID") @PathVariable Long id) {
AuditLogResponse response = auditLogService.getAuditLogById(id);
return Result.success(response);
}
}
@@ -0,0 +1,206 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.annotation.RateLimit;
import com.kurihada.auth.dto.*;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.service.AuthService;
import com.kurihada.auth.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
/**
* 认证控制器
*/
@Tag(name = "认证管理", description = "用户登录、注册相关接口")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtUtil jwtUtil;
private final UserMapper userMapper;
@Value("${jwt.header}")
private String tokenHeader;
@Value("${jwt.prefix}")
private String tokenPrefix;
/**
* 用户登录
*/
@Operation(summary = "用户登录", description = "通过用户名和密码登录,返回JWT Token")
@PostMapping("/login")
@RateLimit(
key = "login",
window = 60,
maxRequests = 5,
limitType = RateLimit.LimitType.IP_AND_USER,
message = "登录请求过于频繁,请稍后再试"
)
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
LoginResponse response = authService.login(request, httpRequest);
return Result.success("登录成功", response);
}
/**
* 用户注册
*/
@Operation(summary = "用户注册", description = "注册新用户账号")
@PostMapping("/register")
@RateLimit(
key = "register",
window = 300,
maxRequests = 3,
limitType = RateLimit.LimitType.IP,
message = "注册请求过于频繁,请稍后再试"
)
public Result<Void> register(@Valid @RequestBody RegisterRequest request, HttpServletRequest httpRequest) {
authService.register(request, httpRequest);
return Result.success();
}
/**
* 刷新 Access Token
*/
@Operation(summary = "刷新Token", description = "使用 Refresh Token 获取新的 Access Token")
@PostMapping("/refresh")
@RateLimit(
key = "refresh",
window = 60,
maxRequests = 10,
limitType = RateLimit.LimitType.IP,
message = "刷新Token请求过于频繁,请稍后再试"
)
public Result<LoginResponse> refreshToken(@Valid @RequestBody RefreshTokenRequest request) {
LoginResponse response = authService.refreshAccessToken(request.getRefreshToken());
return Result.success("Token刷新成功", response);
}
/**
* 用户退出登录
*/
@Operation(summary = "退出登录", description = "退出登录,使当前 Token 失效")
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
// 从请求中获取 token
String token = getJwtFromRequest(request);
Long userId = null;
Long tenantId = null;
// 从 token 中解析用户信息
if (token != null) {
try {
// 从 token 获取用户名和租户ID
String username = jwtUtil.getUsernameFromToken(token);
tenantId = jwtUtil.getTenantIdFromToken(token);
// 根据用户名查询用户获取用户ID
if (username != null) {
User user = userMapper.selectUserWithRolesAndPermissions(username);
if (user != null) {
userId = user.getId();
// 如果token中没有tenantId,使用用户的tenantId
if (tenantId == null) {
tenantId = user.getTenantId();
}
}
}
} catch (Exception e) {
// Token 解析失败,仍然尝试将其加入黑名单
}
}
authService.logout(token, userId, tenantId);
return Result.success();
}
/**
* 忘记密码 - 发送重置邮件
*/
@Operation(summary = "忘记密码", description = "发送密码重置邮件到用户邮箱")
@PostMapping("/forgot-password")
@RateLimit(
key = "forgot-password",
window = 300,
maxRequests = 3,
limitType = RateLimit.LimitType.IP,
message = "请求过于频繁,请稍后再试"
)
public Result<String> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
authService.forgotPassword(request);
return Result.success("如果该邮箱已注册,我们已向其发送密码重置邮件");
}
/**
* 重置密码
*/
@Operation(summary = "重置密码", description = "通过重置令牌设置新密码")
@PostMapping("/reset-password")
@RateLimit(
key = "reset-password",
window = 60,
maxRequests = 5,
limitType = RateLimit.LimitType.IP,
message = "请求过于频繁,请稍后再试"
)
public Result<String> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
authService.resetPassword(request);
return Result.success("密码重置成功,请使用新密码登录");
}
/**
* 验证邮箱
*/
@Operation(summary = "验证邮箱", description = "通过验证令牌验证用户邮箱")
@PostMapping("/verify-email")
@RateLimit(
key = "verify-email",
window = 60,
maxRequests = 10,
message = "请求过于频繁,请稍后再试"
)
public Result<String> verifyEmail(@Valid @RequestBody com.kurihada.auth.dto.VerifyEmailRequest request) {
authService.verifyEmail(request.getToken(), request.getTenantId());
return Result.success("邮箱验证成功!现在可以登录了");
}
/**
* 重新发送验证邮件
*/
@Operation(summary = "重新发送验证邮件", description = "为指定邮箱重新发送验证邮件")
@PostMapping("/resend-verification")
@RateLimit(
key = "resend-verification",
window = 300,
maxRequests = 3,
message = "请求过于频繁,请稍后再试"
)
public Result<String> resendVerification(@Valid @RequestBody com.kurihada.auth.dto.ResendVerificationRequest request) {
authService.resendVerificationEmail(request.getEmail(), request.getTenantId());
return Result.success("验证邮件已发送,请检查您的邮箱");
}
/**
* 从请求中获取JWT token
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(tokenHeader);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,110 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.dto.*;
import com.kurihada.auth.service.PermissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
import java.util.List;
/**
* 权限控制器
*/
@Tag(name = "权限管理", description = "权限信息管理相关接口")
@RestController
@RequestMapping("/api/v1/permissions")
@RequiredArgsConstructor
@SecurityRequirement(name = "Bearer")
public class PermissionController {
private final PermissionService permissionService;
/**
* 分页查询权限
*/
@Operation(summary = "分页查询权限", description = "分页查询权限列表,支持搜索和排序")
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<PageResult<PermissionDTO>> getPermissionsPage(@Valid @ModelAttribute PageRequest pageRequest) {
PageResult<PermissionDTO> result = permissionService.getPermissionsPage(pageRequest);
return Result.success(result);
}
/**
* 根据ID获取权限
*/
@Operation(summary = "根据ID获取权限", description = "根据权限ID查询权限详细信息")
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<PermissionDTO> getPermissionById(
@Parameter(description = "权限ID") @PathVariable Long id) {
PermissionDTO permission = permissionService.getPermissionById(id);
return Result.success(permission);
}
/**
* 创建新权限
*/
@Operation(summary = "创建权限", description = "创建新的权限(仅管理员)")
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public Result<PermissionDTO> createPermission(
@Valid @RequestBody CreatePermissionRequest request) {
PermissionDTO permission = permissionService.createPermission(request);
return Result.success(permission);
}
/**
* 更新权限
*/
@Operation(summary = "更新权限", description = "更新指定权限信息(仅管理员)")
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<PermissionDTO> updatePermission(
@Parameter(description = "权限ID") @PathVariable Long id,
@Valid @RequestBody UpdatePermissionRequest request) {
PermissionDTO permission = permissionService.updatePermission(id, request);
return Result.success(permission);
}
/**
* 删除权限
*/
@Operation(summary = "删除权限", description = "删除指定权限(仅管理员)")
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> deletePermission(
@Parameter(description = "权限ID") @PathVariable Long id) {
permissionService.deletePermission(id);
return Result.success();
}
/**
* 切换权限状态
*/
@Operation(summary = "切换权限状态", description = "启用或禁用指定权限(仅管理员)")
@PutMapping("/{id}/toggle-status")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> togglePermissionStatus(
@Parameter(description = "权限ID") @PathVariable Long id) {
permissionService.togglePermissionStatus(id);
return Result.success();
}
/**
* 获取权限树形结构
*/
@Operation(summary = "获取权限树", description = "获取权限的树形结构(用于菜单展示)")
@GetMapping("/tree")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<List<PermissionTreeNode>> getPermissionTree() {
List<PermissionTreeNode> tree = permissionService.getPermissionTree();
return Result.success(tree);
}
}
@@ -0,0 +1,167 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.dto.*;
import com.kurihada.auth.service.RolePermissionService;
import com.kurihada.auth.service.RoleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
import java.util.List;
/**
* 角色控制器
*/
@Tag(name = "角色管理", description = "角色信息管理相关接口")
@RestController
@RequestMapping("/api/v1/roles")
@RequiredArgsConstructor
@SecurityRequirement(name = "Bearer")
public class RoleController {
private final RoleService roleService;
private final RolePermissionService rolePermissionService;
/**
* 分页查询角色
*/
@Operation(summary = "分页查询角色", description = "分页查询角色列表,支持搜索和排序")
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<PageResult<RoleDTO>> getRolesPage(@Valid @ModelAttribute PageRequest pageRequest) {
PageResult<RoleDTO> result = roleService.getRolesPage(pageRequest);
return Result.success(result);
}
/**
* 根据ID获取角色
*/
@Operation(summary = "根据ID获取角色", description = "根据角色ID查询角色详细信息")
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<RoleDTO> getRoleById(
@Parameter(description = "角色ID") @PathVariable Long id) {
RoleDTO role = roleService.getRoleById(id);
return Result.success(role);
}
/**
* 创建新角色
*/
@Operation(summary = "创建角色", description = "创建新的角色(仅管理员)")
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public Result<RoleDTO> createRole(
@Valid @RequestBody CreateRoleRequest request) {
RoleDTO role = roleService.createRole(request);
return Result.success(role);
}
/**
* 更新角色
*/
@Operation(summary = "更新角色", description = "更新指定角色信息(仅管理员)")
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<RoleDTO> updateRole(
@Parameter(description = "角色ID") @PathVariable Long id,
@Valid @RequestBody UpdateRoleRequest request) {
RoleDTO role = roleService.updateRole(id, request);
return Result.success(role);
}
/**
* 删除角色
*/
@Operation(summary = "删除角色", description = "删除指定角色(仅管理员)")
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> deleteRole(
@Parameter(description = "角色ID") @PathVariable Long id) {
roleService.deleteRole(id);
return Result.success();
}
/**
* 切换角色状态
*/
@Operation(summary = "切换角色状态", description = "启用或禁用指定角色(仅管理员)")
@PutMapping("/{id}/toggle-status")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> toggleRoleStatus(
@Parameter(description = "角色ID") @PathVariable Long id) {
roleService.toggleRoleStatus(id);
return Result.success();
}
/**
* 获取角色的所有权限
*/
@Operation(summary = "获取角色权限", description = "获取指定角色的所有权限列表")
@GetMapping("/{id}/permissions")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<List<PermissionDTO>> getRolePermissions(
@Parameter(description = "角色ID") @PathVariable Long id) {
List<com.kurihada.auth.entity.Permission> permissions = rolePermissionService.getPermissionsByRoleId(id);
List<PermissionDTO> permissionDTOs = permissions.stream()
.map(permission -> PermissionDTO.builder()
.id(permission.getId())
.name(permission.getName())
.code(permission.getCode())
.tenantId(permission.getTenantId())
.type(permission.getType())
.resource(permission.getResource())
.parentId(permission.getParentId())
.sort(permission.getSort())
.status(permission.getStatus())
.createTime(permission.getCreateTime())
.updateTime(permission.getUpdateTime())
.build())
.toList();
return Result.success(permissionDTOs);
}
/**
* 为角色分配权限(批量)
*/
@Operation(summary = "分配权限", description = "为指定角色分配权限(批量,仅管理员)")
@PostMapping("/{id}/permissions")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> assignPermissionsToRole(
@Parameter(description = "角色ID") @PathVariable Long id,
@Valid @RequestBody AssignPermissionsRequest request) {
rolePermissionService.assignPermissionsToRole(id, request.getPermissionIds());
return Result.success();
}
/**
* 移除角色的指定权限
*/
@Operation(summary = "移除权限", description = "移除角色的指定权限(仅管理员)")
@DeleteMapping("/{roleId}/permissions/{permissionId}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> removePermissionFromRole(
@Parameter(description = "角色ID") @PathVariable Long roleId,
@Parameter(description = "权限ID") @PathVariable Long permissionId) {
rolePermissionService.removePermissionFromRole(roleId, permissionId);
return Result.success();
}
/**
* 更新角色的权限(替换)
*/
@Operation(summary = "更新角色权限", description = "替换角色的所有权限(仅管理员)")
@PutMapping("/{id}/permissions")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> updateRolePermissions(
@Parameter(description = "角色ID") @PathVariable Long id,
@Valid @RequestBody AssignPermissionsRequest request) {
rolePermissionService.updateRolePermissions(id, request.getPermissionIds());
return Result.success();
}
}
@@ -0,0 +1,30 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.dto.Result;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 测试控制器
*/
@RestController
@RequestMapping("/api/v1/test")
public class TestController {
/**
* 健康检查
*/
@GetMapping("/health")
public Result<String> health() {
return Result.success("服务运行正常");
}
/**
* 需要认证的接口
*/
@GetMapping("/protected")
public Result<String> protectedEndpoint() {
return Result.success("您已通过认证");
}
}
@@ -0,0 +1,202 @@
package com.kurihada.auth.controller;
import com.kurihada.auth.dto.*;
import com.kurihada.auth.service.UserRoleService;
import com.kurihada.auth.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
import java.util.List;
/**
* 用户控制器
*/
@Tag(name = "用户管理", description = "用户信息管理相关接口")
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@SecurityRequirement(name = "Bearer")
public class UserController {
private final UserService userService;
private final UserRoleService userRoleService;
/**
* 获取当前登录用户信息
*/
@Operation(summary = "获取当前用户信息", description = "获取当前登录用户的详细信息")
@GetMapping("/current")
public Result<UserDTO> getCurrentUser() {
UserDTO user = userService.getCurrentUser();
return Result.success(user);
}
/**
* 更新当前用户信息
*/
@Operation(summary = "更新当前用户信息", description = "当前登录用户更新自己的基本信息")
@PutMapping("/current")
public Result<UserDTO> updateCurrentUser(@Valid @RequestBody UpdateUserRequest request) {
UserDTO user = userService.updateCurrentUser(request);
return Result.success(user);
}
/**
* 根据ID获取用户信息
*/
@Operation(summary = "根据ID获取用户", description = "根据用户ID查询用户详细信息")
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<UserDTO> getUserById(
@Parameter(description = "用户ID") @PathVariable Long id) {
UserDTO user = userService.getUserById(id);
return Result.success(user);
}
/**
* 分页查询用户列表
*/
@Operation(summary = "分页查询用户", description = "分页查询用户列表,支持搜索和排序(仅管理员)")
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public Result<PageResult<UserDTO>> getUsersPage(@Valid @ModelAttribute PageRequest pageRequest) {
PageResult<UserDTO> result = userService.getUsersPage(pageRequest);
return Result.success(result);
}
/**
* 删除用户
*/
@Operation(summary = "删除用户", description = "逻辑删除指定用户(仅管理员)")
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> deleteUser(
@Parameter(description = "用户ID") @PathVariable Long id) {
userService.deleteUser(id);
return Result.success();
}
/**
* 更新用户信息
*/
@Operation(summary = "更新用户信息", description = "更新指定用户的基本信息(仅管理员)")
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<UserDTO> updateUser(
@Parameter(description = "用户ID") @PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserDTO user = userService.updateUser(id, request);
return Result.success(user);
}
/**
* 启用/禁用用户
*/
@Operation(summary = "切换用户状态", description = "启用或禁用指定用户(仅管理员)")
@PutMapping("/{id}/toggle-status")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> toggleUserStatus(
@Parameter(description = "用户ID") @PathVariable Long id) {
userService.toggleUserStatus(id);
return Result.success();
}
/**
* 获取用户的所有角色
*/
@Operation(summary = "获取用户角色", description = "获取指定用户的所有角色列表")
@GetMapping("/{id}/roles")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public Result<List<RoleDTO>> getUserRoles(
@Parameter(description = "用户ID") @PathVariable Long id) {
List<com.kurihada.auth.entity.Role> roles = userRoleService.getRolesByUserId(id);
List<RoleDTO> roleDTOs = roles.stream()
.map(role -> RoleDTO.builder()
.id(role.getId())
.name(role.getName())
.code(role.getCode())
.tenantId(role.getTenantId())
.description(role.getDescription())
.status(role.getStatus())
.createTime(role.getCreateTime())
.updateTime(role.getUpdateTime())
.build())
.toList();
return Result.success(roleDTOs);
}
/**
* 为用户分配角色(批量)
*/
@Operation(summary = "分配角色", description = "为指定用户分配角色(批量,仅管理员)")
@PostMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> assignRolesToUser(
@Parameter(description = "用户ID") @PathVariable Long id,
@Valid @RequestBody AssignRolesRequest request) {
userRoleService.assignRolesToUser(id, request.getRoleIds());
return Result.success();
}
/**
* 移除用户的指定角色
*/
@Operation(summary = "移除角色", description = "移除用户的指定角色(仅管理员)")
@DeleteMapping("/{userId}/roles/{roleId}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> removeRoleFromUser(
@Parameter(description = "用户ID") @PathVariable Long userId,
@Parameter(description = "角色ID") @PathVariable Long roleId) {
userRoleService.removeRoleFromUser(userId, roleId);
return Result.success();
}
/**
* 更新用户的角色(替换)
*/
@Operation(summary = "更新用户角色", description = "替换用户的所有角色(仅管理员)")
@PutMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> updateUserRoles(
@Parameter(description = "用户ID") @PathVariable Long id,
@Valid @RequestBody AssignRolesRequest request) {
userRoleService.updateUserRoles(id, request.getRoleIds());
return Result.success();
}
/**
* 修改当前用户密码
*/
@Operation(summary = "修改密码", description = "当前用户修改自己的密码")
@PutMapping("/password")
public Result<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
userService.changePassword(request);
return Result.success();
}
/**
* 获取当前用户的权限列表
*/
@Operation(summary = "获取当前用户权限", description = "获取当前登录用户的所有权限(包括通过角色继承的)")
@GetMapping("/permissions")
public Result<List<PermissionDTO>> getCurrentUserPermissions() {
List<PermissionDTO> permissions = userService.getCurrentUserPermissions();
return Result.success(permissions);
}
/**
* 获取当前用户的菜单树
*/
@Operation(summary = "获取当前用户菜单", description = "获取当前登录用户可访问的菜单树形结构")
@GetMapping("/menus")
public Result<List<PermissionTreeNode>> getCurrentUserMenus() {
List<PermissionTreeNode> menus = userService.getCurrentUserMenus();
return Result.success(menus);
}
}
@@ -0,0 +1,23 @@
package com.kurihada.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分配权限请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分配权限请求")
public class AssignPermissionsRequest {
@Schema(description = "权限ID列表", example = "[1, 2, 3]")
@NotEmpty(message = "权限ID列表不能为空")
private List<Long> permissionIds;
}
@@ -0,0 +1,23 @@
package com.kurihada.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分配角色请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分配角色请求")
public class AssignRolesRequest {
@Schema(description = "角色ID列表", example = "[1, 2, 3]")
@NotEmpty(message = "角色ID列表不能为空")
private List<Long> roleIds;
}
@@ -0,0 +1,67 @@
package com.kurihada.auth.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 审计日志查询请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AuditLogQueryRequest extends PageRequest {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名(模糊查询)
*/
private String username;
/**
* 操作类型
*/
private String actionType;
/**
* 资源类型
*/
private String resourceType;
/**
* 资源ID
*/
private String resourceId;
/**
* 操作结果(SUCCESS, FAILURE
*/
private String result;
/**
* 租户ID
*/
private Long tenantId;
/**
* 开始时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startDate;
/**
* 结束时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endDate;
/**
* IP地址(精确匹配)
*/
private String ipAddress;
}
@@ -0,0 +1,85 @@
package com.kurihada.auth.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 审计日志响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditLogResponse {
/**
* 日志ID
*/
private Long id;
/**
* 操作类型
*/
private String actionType;
/**
* 操作用户ID
*/
private Long userId;
/**
* 操作用户名
*/
private String username;
/**
* 租户ID
*/
private Long tenantId;
/**
* 目标资源类型
*/
private String resourceType;
/**
* 目标资源ID
*/
private String resourceId;
/**
* 操作详情
*/
private String details;
/**
* 操作结果(SUCCESS, FAILURE
*/
private String result;
/**
* 错误信息(如果失败)
*/
private String errorMessage;
/**
* 请求IP
*/
private String ipAddress;
/**
* User Agent
*/
private String userAgent;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}
@@ -0,0 +1,27 @@
package com.kurihada.auth.dto;
import com.kurihada.auth.annotation.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 修改密码请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "修改密码请求")
public class ChangePasswordRequest {
@Schema(description = "旧密码", example = "OldPass123!")
@NotBlank(message = "旧密码不能为空")
private String oldPassword;
@Schema(description = "新密码", example = "NewPass123!")
@NotBlank(message = "新密码不能为空")
@ValidPassword
private String newPassword;
}
@@ -0,0 +1,33 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 创建权限请求
*/
@Data
public class CreatePermissionRequest {
@NotBlank(message = "权限名称不能为空")
@Size(max = 50, message = "权限名称长度不能超过50个字符")
private String name;
@NotBlank(message = "权限代码不能为空")
@Size(max = 100, message = "权限代码长度不能超过100个字符")
private String code;
@Size(max = 255, message = "权限描述长度不能超过255个字符")
private String description;
@Size(max = 20, message = "权限类型长度不能超过20个字符")
private String type;
@Size(max = 255, message = "资源路径长度不能超过255个字符")
private String resource;
private Long parentId;
private Integer sort = 0;
}
@@ -0,0 +1,23 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 创建角色请求
*/
@Data
public class CreateRoleRequest {
@NotBlank(message = "角色名称不能为空")
@Size(max = 50, message = "角色名称长度不能超过50个字符")
private String name;
@NotBlank(message = "角色代码不能为空")
@Size(max = 50, message = "角色代码长度不能超过50个字符")
private String code;
@Size(max = 255, message = "角色描述长度不能超过255个字符")
private String description;
}
@@ -0,0 +1,20 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 忘记密码请求DTO
*/
@Data
public class ForgotPasswordRequest {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotNull(message = "租户ID不能为空")
private Long tenantId;
}
@@ -0,0 +1,31 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求DTO
*/
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
/**
* 租户代码(可选)
* 如果不提供,系统会按以下优先级查找:
* 1. 请求头 X-Tenant-Code
* 2. 子域名(如 tenant1.example.com
* 3. 使用默认租户
*/
private String tenantCode;
/**
* 租户ID(可选,一般使用 tenantCode
*/
private Long tenantId;
}
@@ -0,0 +1,34 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Set;
/**
* 登录响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
private String refreshToken;
private String tokenType = "Bearer";
private Long userId;
private String username;
private String realName;
private Set<String> roles;
private Set<String> permissions;
}
@@ -0,0 +1,54 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
/**
* 分页请求参数
*/
@Data
public class PageRequest {
/**
* 当前页码(从1开始)
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页大小
*/
@Min(value = 1, message = "每页大小必须大于0")
@Max(value = 100, message = "每页大小不能超过100")
private Integer size = 10;
/**
* 排序字段
*/
private String sortBy;
/**
* 排序方向(asc/desc
*/
private String sortDirection = "desc";
/**
* 搜索关键词
*/
private String keyword;
/**
* 获取 MyBatis Plus 的偏移量
*/
public long getOffset() {
return (long) (page - 1) * size;
}
/**
* 获取限制数量
*/
public long getLimit() {
return size;
}
}
@@ -0,0 +1,69 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分页响应结果
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
/**
* 数据列表
*/
private List<T> records;
/**
* 总记录数
*/
private Long total;
/**
* 当前页码
*/
private Integer page;
/**
* 每页大小
*/
private Integer size;
/**
* 总页数
*/
private Integer totalPages;
/**
* 是否有下一页
*/
private Boolean hasNext;
/**
* 是否有上一页
*/
private Boolean hasPrevious;
/**
* 创建分页结果
*/
public static <T> PageResult<T> of(List<T> records, Long total, Integer page, Integer size) {
int totalPages = (int) Math.ceil((double) total / size);
return PageResult.<T>builder()
.records(records)
.total(total)
.page(page)
.size(size)
.totalPages(totalPages)
.hasNext(page < totalPages)
.hasPrevious(page > 1)
.build();
}
}
@@ -0,0 +1,42 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 权限信息DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionDTO {
private Long id;
private String name;
private String code;
private Long tenantId;
private String description;
private String type;
private String resource;
private Long parentId;
private Integer sort;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,45 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 权限树节点DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionTreeNode {
private Long id;
private String name;
private String code;
private Long tenantId;
private String type;
private String resource;
private Long parentId;
private Integer sort;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@Builder.Default
private List<PermissionTreeNode> children = new ArrayList<>();
}
@@ -0,0 +1,14 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 刷新Token请求
*/
@Data
public class RefreshTokenRequest {
@NotBlank(message = "Refresh Token不能为空")
private String refreshToken;
}
@@ -0,0 +1,56 @@
package com.kurihada.auth.dto;
import com.kurihada.auth.annotation.ValidPassword;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 注册请求DTO
*/
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50个字符之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线")
private String username;
@NotBlank(message = "密码不能为空")
@ValidPassword(
minLength = 8,
maxLength = 32,
requireLowercase = true,
requireUppercase = true,
requireDigit = true,
requireSpecialChar = true,
forbidCommonPasswords = true
)
private String password;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
@Email(message = "邮箱格式不正确")
private String email;
private String realName;
private String phone;
/**
* 租户代码(可选)
* 如果不提供,系统会按以下优先级查找:
* 1. 请求头 X-Tenant-Code
* 2. 子域名(如 tenant1.example.com
* 3. 使用默认租户
*/
private String tenantCode;
/**
* 租户ID(可选,一般使用 tenantCode
*/
private Long tenantId;
}
@@ -0,0 +1,20 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 重新发送验证邮件请求
*/
@Data
public class ResendVerificationRequest {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotNull(message = "租户ID不能为空")
private Long tenantId;
}
@@ -0,0 +1,31 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 重置密码请求DTO
*/
@Data
public class ResetPasswordRequest {
@NotBlank(message = "重置令牌不能为空")
private String token;
@NotNull(message = "租户ID不能为空")
private Long tenantId;
@NotBlank(message = "新密码不能为空")
@Size(min = 8, max = 32, message = "密码长度必须在8-32位之间")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]).+$",
message = "密码必须包含大小写字母、数字和特殊字符"
)
private String newPassword;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}
@@ -0,0 +1,64 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应结果
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
/**
* 响应码
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 成功响应
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}
/**
* 成功响应(无数据)
*/
public static <T> Result<T> success() {
return new Result<>(200, "操作成功", null);
}
/**
* 成功响应(自定义消息)
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data);
}
/**
* 失败响应
*/
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
/**
* 失败响应(自定义响应码)
*/
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
}
@@ -0,0 +1,37 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 角色信息DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoleDTO {
private Long id;
private String name;
private String code;
private Long tenantId;
private String description;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Set<String> permissions;
}
@@ -0,0 +1,29 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 更新权限请求
*/
@Data
public class UpdatePermissionRequest {
@NotBlank(message = "权限名称不能为空")
@Size(max = 50, message = "权限名称长度不能超过50个字符")
private String name;
@Size(max = 255, message = "权限描述长度不能超过255个字符")
private String description;
@Size(max = 20, message = "权限类型长度不能超过20个字符")
private String type;
@Size(max = 255, message = "资源路径长度不能超过255个字符")
private String resource;
private Long parentId;
private Integer sort;
}
@@ -0,0 +1,19 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 更新角色请求
*/
@Data
public class UpdateRoleRequest {
@NotBlank(message = "角色名称不能为空")
@Size(max = 50, message = "角色名称长度不能超过50个字符")
private String name;
@Size(max = 255, message = "角色描述长度不能超过255个字符")
private String description;
}
@@ -0,0 +1,37 @@
package com.kurihada.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 更新用户信息请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "更新用户信息请求")
public class UpdateUserRequest {
@Schema(description = "真实姓名", example = "张三")
@Size(max = 100, message = "真实姓名长度不能超过100个字符")
private String realName;
@Schema(description = "邮箱", example = "user@example.com")
@Email(message = "邮箱格式不正确")
private String email;
@Schema(description = "手机号", example = "13800138000")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "性别:0-未知,1-男,2-女", example = "1")
private Integer gender;
@Schema(description = "头像URL", example = "https://example.com/avatar.jpg")
private String avatar;
}
@@ -0,0 +1,43 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 用户信息DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String username;
private String realName;
private String email;
private String phone;
private Integer gender;
private String avatar;
private Integer status;
private LocalDateTime lastLoginTime;
private LocalDateTime createTime;
private Set<String> roles;
private Set<String> permissions;
}
@@ -0,0 +1,83 @@
package com.kurihada.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 用户权限缓存DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserPermissionCache implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 租户ID
*/
private Long tenantId;
/**
* 用户名
*/
private String username;
/**
* 角色代码列表(初始化为空列表,避免 null)
*/
private List<String> roleCodes = new ArrayList<>();
/**
* 角色名称列表(初始化为空列表,避免 null)
*/
private List<String> roleNames = new ArrayList<>();
/**
* 权限代码集合(初始化为空集合,避免 null)
*/
private Set<String> permissionCodes = new HashSet<>();
/**
* 权限资源集合(API路径,初始化为空集合,避免 null)
*/
private Set<String> resources = new HashSet<>();
/**
* 缓存时间戳
*/
private Long cacheTime;
/**
* 检查是否拥有某个权限
*/
public boolean hasPermission(String permissionCode) {
return permissionCodes != null && permissionCodes.contains(permissionCode);
}
/**
* 检查是否拥有某个角色
*/
public boolean hasRole(String roleCode) {
return roleCodes != null && roleCodes.contains(roleCode);
}
/**
* 检查是否有权限访问某个资源
*/
public boolean hasResource(String resource) {
return resources != null && resources.contains(resource);
}
}
@@ -0,0 +1,18 @@
package com.kurihada.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 邮箱验证请求
*/
@Data
public class VerifyEmailRequest {
@NotBlank(message = "验证令牌不能为空")
private String token;
@NotNull(message = "租户ID不能为空")
private Long tenantId;
}
@@ -0,0 +1,96 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 审计日志实体
* 用于记录系统中的重要操作
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("audit_log")
public class AuditLog {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 操作类型(LOGIN, LOGOUT, CREATE_USER, UPDATE_USER, DELETE_USER, ASSIGN_ROLE, etc.
*/
@TableField("action_type")
private String actionType;
/**
* 操作用户ID
*/
@TableField("user_id")
private Long userId;
/**
* 操作用户名
*/
@TableField("username")
private String username;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 目标资源类型(USER, ROLE, PERMISSION
*/
@TableField("resource_type")
private String resourceType;
/**
* 目标资源ID
*/
@TableField("resource_id")
private String resourceId;
/**
* 操作详情
*/
@TableField("details")
private String details;
/**
* 操作结果(SUCCESS, FAILURE
*/
@TableField("result")
private String result;
/**
* 错误信息(如果失败)
*/
@TableField("error_message")
private String errorMessage;
/**
* 请求IP
*/
@TableField("ip_address")
private String ipAddress;
/**
* User Agent
*/
@TableField("user_agent")
private String userAgent;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
@@ -0,0 +1,87 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 权限实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("permissions")
public class Permission {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 权限名称
*/
@TableField("name")
private String name;
/**
* 权限代码
*/
@TableField("code")
private String code;
/**
* 租户ID(null表示公共权限)
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 权限描述
*/
@TableField("description")
private String description;
/**
* 权限类型(如:menu, button, api等)
*/
@TableField("type")
private String type;
/**
* 资源路径
*/
@TableField("resource")
private String resource;
/**
* 父权限ID
*/
@TableField("parent_id")
private Long parentId;
/**
* 排序
*/
@TableField("sort")
private Integer sort;
/**
* 状态(0:禁用,1:启用)
*/
@TableField("status")
private Integer status = 1;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@@ -0,0 +1,70 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Refresh Token 实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("refresh_tokens")
public class RefreshToken {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
@TableField("user_id")
private Long userId;
/**
* Refresh Token
*/
@TableField("token")
private String token;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 过期时间
*/
@TableField("expire_time")
private LocalDateTime expireTime;
/**
* 是否已使用(已使用的token不能再次刷新)
*/
@TableField("used")
private Boolean used = false;
/**
* 是否删除(0:未删除,1:已删除)
*/
@TableLogic
@TableField("deleted")
private Integer deleted = 0;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@@ -0,0 +1,70 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 角色实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("roles")
public class Role {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 角色名称
*/
@TableField("name")
private String name;
/**
* 角色代码
*/
@TableField("code")
private String code;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 角色描述
*/
@TableField("description")
private String description;
/**
* 状态(0:禁用,1:启用)
*/
@TableField("status")
private Integer status = 1;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 角色拥有的权限集合(非数据库字段)
*/
@TableField(exist = false)
private List<Permission> permissions;
}
@@ -0,0 +1,60 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 角色权限关联实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("role_permissions")
public class RolePermission {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 角色ID
*/
@TableField("role_id")
private Long roleId;
/**
* 权限ID
*/
@TableField("permission_id")
private Long permissionId;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 角色信息(非数据库字段)
*/
@TableField(exist = false)
private Role role;
/**
* 权限信息(非数据库字段)
*/
@TableField(exist = false)
private Permission permission;
}
@@ -0,0 +1,82 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 租户实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("tenants")
public class Tenant {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 租户代码(唯一标识)
*/
@TableField("code")
private String code;
/**
* 租户名称
*/
@TableField("name")
private String name;
/**
* 联系人
*/
@TableField("contact_name")
private String contactName;
/**
* 联系电话
*/
@TableField("contact_phone")
private String contactPhone;
/**
* 联系邮箱
*/
@TableField("contact_email")
private String contactEmail;
/**
* 状态(0:禁用,1:启用)
*/
@TableField("status")
private Integer status = 1;
/**
* 是否删除(0:未删除,1:已删除)
*/
@TableLogic
@TableField("deleted")
private Integer deleted = 0;
/**
* 过期时间
*/
@TableField("expire_time")
private LocalDateTime expireTime;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@@ -0,0 +1,137 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("users")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
@TableField("username")
private String username;
/**
* 密码(加密后)
*/
@TableField("password")
private String password;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 真实姓名
*/
@TableField("real_name")
private String realName;
/**
* 邮箱
*/
@TableField("email")
private String email;
/**
* 手机号
*/
@TableField("phone")
private String phone;
/**
* 性别(0:女,1:男,2:未知)
*/
@TableField("gender")
private Integer gender;
/**
* 头像
*/
@TableField("avatar")
private String avatar;
/**
* 状态(0:禁用,1:启用)
*/
@TableField("status")
private Integer status = 1;
/**
* 是否删除(0:未删除,1:已删除)
*/
@TableLogic
@TableField("deleted")
private Integer deleted = 0;
/**
* 邮箱是否已验证(0:未验证,1:已验证)
*/
@TableField("email_verified")
private Integer emailVerified = 0;
/**
* 邮箱验证时间
*/
@TableField("email_verified_at")
private LocalDateTime emailVerifiedAt;
/**
* 登录失败次数
*/
@TableField("login_failed_count")
private Integer loginFailedCount = 0;
/**
* 账户锁定时间
*/
@TableField("account_locked_at")
private LocalDateTime accountLockedAt;
/**
* 账户解锁时间
*/
@TableField("account_unlock_at")
private LocalDateTime accountUnlockAt;
/**
* 最后登录时间
*/
@TableField("last_login_time")
private LocalDateTime lastLoginTime;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 用户拥有的角色集合(非数据库字段)
*/
@TableField(exist = false)
private List<Role> roles;
}
@@ -0,0 +1,60 @@
package com.kurihada.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 用户角色关联实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("user_roles")
public class UserRole {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
@TableField("user_id")
private Long userId;
/**
* 角色ID
*/
@TableField("role_id")
private Long roleId;
/**
* 租户ID
*/
@TableField("tenant_id")
private Long tenantId;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 用户信息(非数据库字段)
*/
@TableField(exist = false)
private User user;
/**
* 角色信息(非数据库字段)
*/
@TableField(exist = false)
private Role role;
}
@@ -0,0 +1,27 @@
package com.kurihada.auth.exception;
import lombok.Getter;
/**
* 业务异常类
*/
@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(String message) {
super(message);
this.code = 500;
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
this.code = 500;
}
}
@@ -0,0 +1,186 @@
package com.kurihada.auth.exception;
import com.kurihada.auth.dto.Result;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler(BusinessException.class)
public Result<Object> handleBusinessException(BusinessException e) {
log.error("业务异常: {}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
/**
* 处理限流异常
*/
@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public Result<Object> handleRateLimitException(RateLimitException e) {
log.warn("触发限流: {}, 需等待: {}秒", e.getMessage(), e.getRemainingTime());
return Result.error(429, e.getMessage());
}
/**
* 处理参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.error("参数校验异常: {}", message);
return Result.error(400, message);
}
/**
* 处理绑定异常
*/
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.error("参数绑定异常: {}", message);
return Result.error(400, message);
}
/**
* 处理Spring Security认证异常
*/
@ExceptionHandler(BadCredentialsException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<Object> handleBadCredentialsException(BadCredentialsException e) {
log.error("认证失败: {}", e.getMessage());
return Result.error(401, "用户名或密码错误");
}
/**
* 处理Spring Security权限异常
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Object> handleAccessDeniedException(AccessDeniedException e) {
log.error("权限不足: {}", e.getMessage());
return Result.error(403, "权限不足,访问被拒绝");
}
/**
* 处理数据库唯一键冲突异常
*/
@ExceptionHandler(DuplicateKeyException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleDuplicateKeyException(DuplicateKeyException e) {
log.error("数据库唯一键冲突: {}", e.getMessage());
return Result.error(400, "数据已存在,请勿重复添加");
}
/**
* 处理数据访问异常
*/
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Object> handleDataAccessException(DataAccessException e) {
log.error("数据访问异常: ", e);
return Result.error("数据访问失败,请稍后重试");
}
/**
* 处理MyBatis Plus异常
*/
@ExceptionHandler(MybatisPlusException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Object> handleMybatisPlusException(MybatisPlusException e) {
log.error("MyBatis Plus异常: ", e);
return Result.error("数据操作失败");
}
/**
* 处理请求方法不支持异常
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public Result<Object> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.error("请求方法不支持: {}", e.getMessage());
return Result.error(405, "不支持的请求方法: " + e.getMethod());
}
/**
* 处理请求参数缺失异常
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleMissingParameterException(MissingServletRequestParameterException e) {
log.error("缺少请求参数: {}", e.getMessage());
return Result.error(400, "缺少必要参数: " + e.getParameterName());
}
/**
* 处理参数类型不匹配异常
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleTypeMismatchException(MethodArgumentTypeMismatchException e) {
log.error("参数类型不匹配: {}", e.getMessage());
return Result.error(400, "参数类型错误: " + e.getName());
}
/**
* 处理JSON解析异常
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleJsonParseException(HttpMessageNotReadableException e) {
log.error("JSON解析异常: {}", e.getMessage());
return Result.error(400, "请求数据格式错误");
}
/**
* 处理404异常
*/
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Object> handleNotFoundException(NoHandlerFoundException e) {
log.error("请求路径不存在: {}", e.getRequestURL());
return Result.error(404, "请求的资源不存在");
}
/**
* 处理其他异常
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Object> handleException(Exception e) {
log.error("系统异常: ", e);
return Result.error("系统异常,请联系管理员");
}
}
@@ -0,0 +1,23 @@
package com.kurihada.auth.exception;
/**
* 限流异常
*/
public class RateLimitException extends RuntimeException {
private final int remainingTime;
public RateLimitException(String message) {
super(message);
this.remainingTime = 0;
}
public RateLimitException(String message, int remainingTime) {
super(message);
this.remainingTime = remainingTime;
}
public int getRemainingTime() {
return remainingTime;
}
}
@@ -0,0 +1,35 @@
package com.kurihada.auth.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis Plus 自动填充处理器
*/
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入时自动填充
*/
@Override
public void insertFill(MetaObject metaObject) {
log.debug("开始插入填充...");
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
/**
* 更新时自动填充
*/
@Override
public void updateFill(MetaObject metaObject) {
log.debug("开始更新填充...");
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
@@ -0,0 +1,68 @@
package com.kurihada.auth.interceptor;
import com.kurihada.auth.context.TenantContext;
import com.kurihada.auth.util.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
/**
* 租户拦截器 - 从请求中提取租户ID并设置到TenantContext
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TenantInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
@Value("${jwt.header}")
private String tokenHeader;
@Value("${jwt.prefix}")
private String tokenPrefix;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt)) {
// 从JWT的claims中获取tenantId
Long tenantId = jwtUtil.getTenantIdFromToken(jwt);
if (tenantId != null) {
TenantContext.setTenantId(tenantId);
log.debug("设置当前租户ID: {}", tenantId);
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
// 请求处理完成后清除租户上下文
TenantContext.clear();
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 确保清除租户上下文(异常情况下也要清除)
TenantContext.clear();
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(tokenHeader);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,12 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kurihada.auth.entity.AuditLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 审计日志 Mapper
*/
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLog> {
}
@@ -0,0 +1,12 @@
package com.kurihada.auth.mapper;
import com.kurihada.auth.entity.Permission;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 权限Mapper接口
*/
@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {
}
@@ -0,0 +1,12 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kurihada.auth.entity.RefreshToken;
import org.apache.ibatis.annotations.Mapper;
/**
* Refresh Token Mapper
*/
@Mapper
public interface RefreshTokenMapper extends BaseMapper<RefreshToken> {
}
@@ -0,0 +1,12 @@
package com.kurihada.auth.mapper;
import com.kurihada.auth.entity.Role;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色Mapper接口
*/
@Mapper
public interface RoleMapper extends BaseMapper<Role> {
}
@@ -0,0 +1,70 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kurihada.auth.entity.RolePermission;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 角色权限关联Mapper
*/
@Mapper
public interface RolePermissionMapper extends BaseMapper<RolePermission> {
/**
* 根据角色ID查询权限ID列表
*/
@Select("SELECT permission_id FROM role_permissions WHERE role_id = #{roleId}")
List<Long> selectPermissionIdsByRoleId(Long roleId);
/**
* 根据权限ID查询角色ID列表
*/
@Select("SELECT role_id FROM role_permissions WHERE permission_id = #{permissionId}")
List<Long> selectRoleIdsByPermissionId(Long permissionId);
/**
* 为角色分配权限
*/
@Insert("INSERT INTO role_permissions(role_id, permission_id) VALUES(#{roleId}, #{permissionId})")
int insert(Long roleId, Long permissionId);
/**
* 批量为角色分配权限
*/
@Insert("<script>" +
"INSERT INTO role_permissions(role_id, permission_id) VALUES " +
"<foreach collection='permissionIds' item='permissionId' separator=','>" +
"(#{roleId}, #{permissionId})" +
"</foreach>" +
"</script>")
int batchInsert(Long roleId, List<Long> permissionIds);
/**
* 删除角色的所有权限
*/
@Delete("DELETE FROM role_permissions WHERE role_id = #{roleId}")
int deleteByRoleId(Long roleId);
/**
* 删除权限的所有角色关联
*/
@Delete("DELETE FROM role_permissions WHERE permission_id = #{permissionId}")
int deleteByPermissionId(Long permissionId);
/**
* 删除指定角色的指定权限
*/
@Delete("DELETE FROM role_permissions WHERE role_id = #{roleId} AND permission_id = #{permissionId}")
int deleteByRoleIdAndPermissionId(Long roleId, Long permissionId);
/**
* 检查角色是否拥有指定权限
*/
@Select("SELECT COUNT(*) FROM role_permissions WHERE role_id = #{roleId} AND permission_id = #{permissionId}")
int existsByRoleIdAndPermissionId(Long roleId, Long permissionId);
}
@@ -0,0 +1,12 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kurihada.auth.entity.Tenant;
import org.apache.ibatis.annotations.Mapper;
/**
* 租户Mapper
*/
@Mapper
public interface TenantMapper extends BaseMapper<Tenant> {
}
@@ -0,0 +1,28 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.kurihada.auth.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 用户Mapper接口
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户(包含角色和权限,一次查询)
* 忽略租户过滤器,因为登录时还没有租户上下文
*/
@InterceptorIgnore(tenantLine = "true")
User selectUserWithRolesAndPermissions(@Param("username") String username);
/**
* 根据用户ID查询用户(包含角色和权限,一次查询)
* 忽略租户过滤器,用于刷新token等场景
*/
@InterceptorIgnore(tenantLine = "true")
User selectUserWithRolesAndPermissionsById(@Param("userId") Long userId);
}
@@ -0,0 +1,93 @@
package com.kurihada.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kurihada.auth.entity.UserRole;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 用户角色关联Mapper
*/
@Mapper
public interface UserRoleMapper extends BaseMapper<UserRole> {
/**
* 根据用户ID查询角色ID列表
*/
@Select("SELECT role_id FROM user_roles WHERE user_id = #{userId}")
List<Long> selectRoleIdsByUserId(Long userId);
/**
* 根据角色ID查询用户ID列表
*/
@Select("SELECT user_id FROM user_roles WHERE role_id = #{roleId}")
List<Long> selectUserIdsByRoleId(Long roleId);
/**
* 为用户分配角色
*/
@Insert("INSERT INTO user_roles(user_id, role_id) VALUES(#{userId}, #{roleId})")
int insert(Long userId, Long roleId);
/**
* 批量为用户分配角色
*/
@Insert("<script>" +
"INSERT INTO user_roles(user_id, role_id) VALUES " +
"<foreach collection='roleIds' item='roleId' separator=','>" +
"(#{userId}, #{roleId})" +
"</foreach>" +
"</script>")
int batchInsert(Long userId, List<Long> roleIds);
/**
* 批量为角色分配用户
*/
@Insert("<script>" +
"INSERT INTO user_roles(user_id, role_id) VALUES " +
"<foreach collection='userIds' item='userId' separator=','>" +
"(#{userId}, #{roleId})" +
"</foreach>" +
"</script>")
int batchInsertUsers(Long roleId, List<Long> userIds);
/**
* 删除用户的所有角色
*/
@Delete("DELETE FROM user_roles WHERE user_id = #{userId}")
int deleteByUserId(Long userId);
/**
* 删除角色的所有用户关联
*/
@Delete("DELETE FROM user_roles WHERE role_id = #{roleId}")
int deleteByRoleId(Long roleId);
/**
* 删除指定用户的指定角色
*/
@Delete("DELETE FROM user_roles WHERE user_id = #{userId} AND role_id = #{roleId}")
int deleteByUserIdAndRoleId(Long userId, Long roleId);
/**
* 检查用户是否拥有指定角色
*/
@Select("SELECT COUNT(*) FROM user_roles WHERE user_id = #{userId} AND role_id = #{roleId}")
int existsByUserIdAndRoleId(Long userId, Long roleId);
/**
* 统计角色的用户数量
*/
@Select("SELECT COUNT(*) FROM user_roles WHERE role_id = #{roleId}")
int countUsersByRoleId(Long roleId);
/**
* 统计用户的角色数量
*/
@Select("SELECT COUNT(*) FROM user_roles WHERE user_id = #{userId}")
int countRolesByUserId(Long userId);
}
@@ -0,0 +1,36 @@
package com.kurihada.auth.security;
import com.kurihada.auth.dto.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* JWT访问拒绝处理器 - 处理授权失败
*/
@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("访问被拒绝: {}", accessDeniedException.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
Result<Object> result = Result.error(403, "权限不足,访问被拒绝");
ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,36 @@
package com.kurihada.auth.security;
import com.kurihada.auth.dto.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* JWT认证入口点 - 处理认证失败
*/
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.error("认证失败: {}", authException.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Result<Object> result = Result.error(401, "未授权,请先登录");
ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,111 @@
package com.kurihada.auth.security;
import com.kurihada.auth.context.TenantContext;
import com.kurihada.auth.service.PermissionCacheService;
import com.kurihada.auth.util.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.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT认证过滤器
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
private final com.kurihada.auth.service.TokenBlacklistService tokenBlacklistService;
private final PermissionCacheService permissionCacheService;
@Value("${jwt.header}")
private String tokenHeader;
@Value("${jwt.prefix}")
private String tokenPrefix;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
// 检查 token 是否有效且不在黑名单中
if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt) && !tokenBlacklistService.isBlacklisted(jwt)) {
String username = jwtUtil.getUsernameFromToken(jwt);
Long userId = jwtUtil.getUserIdFromToken(jwt);
Long tenantId = jwtUtil.getTenantIdFromToken(jwt);
// 设置租户上下文
if (tenantId != null) {
TenantContext.setTenantId(tenantId);
}
UserDetails userDetails = null;
// 优先从缓存构建 UserDetails(避免数据库查询)
if (userId != null && tenantId != null) {
userDetails = permissionCacheService.buildUserDetailsFromCache(userId, tenantId, username);
if (userDetails != null) {
logger.debug("从缓存构建 UserDetails 成功: userId=" + userId + ", tenantId=" + tenantId);
}
}
// 缓存未命中,回退到数据库查询
if (userDetails == null) {
logger.debug("缓存未命中,从数据库加载 UserDetails: username=" + username);
userDetails = userDetailsService.loadUserByUsername(username);
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("无法设置用户认证: {}", e);
}
// 调用下一个过滤器
filterChain.doFilter(request, response);
// CRITICAL: 请求处理完成后清理租户上下文,防止 ThreadLocal 内存泄露
// 注意:必须在 filterChain.doFilter() 之后执行,确保整个请求链都能访问到租户信息
// SecurityContext 不需要手动清理,Spring Security 会自动处理
TenantContext.clear();
}
/**
* 从请求中获取JWT token
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(tokenHeader);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,91 @@
package com.kurihada.auth.security;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.mapper.PermissionMapper;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.Collection;
import java.util.HashSet;
import java.util.Set;
/**
* UserDetailsService实现类
*/
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 使用优化后的联表查询,一次性加载用户、角色和权限
User user = userMapper.selectUserWithRolesAndPermissions(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
if (user.getStatus() == 0) {
throw new UsernameNotFoundException("用户已被禁用: " + username);
}
return buildUserDetails(user);
}
/**
* 构建UserDetails对象
*/
private UserDetails buildUserDetails(User user) {
Collection<? extends GrantedAuthority> authorities = getAuthorities(user);
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.accountExpired(false)
.accountLocked(user.getStatus() == 0)
.credentialsExpired(false)
.disabled(user.getDeleted() == 1)
.build();
}
/**
* 获取用户权限集合
*/
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
Set<GrantedAuthority> authorities = new HashSet<>();
// 添加角色权限
if (user.getRoles() != null) {
for (Role role : user.getRoles()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getCode()));
// 添加角色对应的权限
if (role.getPermissions() != null) {
for (Permission permission : role.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission.getCode()));
}
}
}
}
return authorities;
}
}
@@ -0,0 +1,50 @@
package com.kurihada.auth.service;
import com.kurihada.auth.entity.User;
/**
* 账户锁定服务接口
* 功能:管理账户登录失败次数和锁定状态
*/
public interface AccountLockService {
/**
* 检查账户是否被锁定
*
* @param user 用户对象
* @return true-已锁定,false-未锁定
*/
boolean isAccountLocked(User user);
/**
* 记录登录失败
* 如果失败次数达到阈值,则锁定账户
*
* @param user 用户对象
*/
void recordLoginFailure(User user);
/**
* 重置登录失败次数
* 在登录成功后调用
*
* @param user 用户对象
*/
void resetLoginFailureCount(User user);
/**
* 解锁账户
* 手动解锁或自动解锁时调用
*
* @param user 用户对象
*/
void unlockAccount(User user);
/**
* 获取账户剩余锁定时间(秒)
*
* @param user 用户对象
* @return 剩余锁定时间(秒),如果未锁定则返回0
*/
long getRemainingLockTime(User user);
}
@@ -0,0 +1,68 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.AuditLogQueryRequest;
import com.kurihada.auth.dto.AuditLogResponse;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.entity.AuditLog;
import jakarta.servlet.http.HttpServletRequest;
/**
* 审计日志服务接口
*/
public interface AuditLogService {
/**
* 记录审计日志
*
* @param auditLog 审计日志对象
*/
void log(AuditLog auditLog);
/**
* 记录成功的操作
*
* @param actionType 操作类型
* @param userId 用户ID
* @param username 用户名
* @param tenantId 租户ID
* @param resourceType 资源类型
* @param resourceId 资源ID
* @param details 操作详情
* @param request HTTP 请求对象
*/
void logSuccess(String actionType, Long userId, String username, Long tenantId,
String resourceType, String resourceId, String details, HttpServletRequest request);
/**
* 记录失败的操作
*
* @param actionType 操作类型
* @param userId 用户ID
* @param username 用户名
* @param tenantId 租户ID
* @param resourceType 资源类型
* @param resourceId 资源ID
* @param details 操作详情
* @param errorMessage 错误信息
* @param request HTTP 请求对象
*/
void logFailure(String actionType, Long userId, String username, Long tenantId,
String resourceType, String resourceId, String details,
String errorMessage, HttpServletRequest request);
/**
* 分页查询审计日志
*
* @param request 查询请求参数
* @return 分页结果
*/
PageResult<AuditLogResponse> queryAuditLogs(AuditLogQueryRequest request);
/**
* 根据ID获取审计日志详情
*
* @param id 日志ID
* @return 审计日志详情
*/
AuditLogResponse getAuditLogById(Long id);
}
@@ -0,0 +1,632 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.ForgotPasswordRequest;
import com.kurihada.auth.dto.LoginRequest;
import com.kurihada.auth.dto.LoginResponse;
import com.kurihada.auth.dto.RegisterRequest;
import com.kurihada.auth.dto.ResetPasswordRequest;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.RefreshToken;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.PermissionMapper;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import com.kurihada.auth.util.JwtUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 认证服务类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
private final RefreshTokenService refreshTokenService;
private final TokenBlacklistService tokenBlacklistService;
private final com.kurihada.auth.util.TenantResolver tenantResolver;
private final PermissionCacheService permissionCacheService;
private final com.kurihada.auth.service.UserRoleService userRoleService;
private final EmailService emailService;
private final PasswordResetTokenService passwordResetTokenService;
private final EmailVerificationTokenService emailVerificationTokenService;
private final AccountLockService accountLockService;
@Value("${registration.auto-assign-default-role:true}")
private boolean autoAssignDefaultRole;
@Value("${registration.default-role-code:USER}")
private String defaultRoleCode;
@Value("${registration.require-email-verification:true}")
private boolean requireEmailVerification;
/**
* 用户登录
*/
@Transactional
public LoginResponse login(LoginRequest request, jakarta.servlet.http.HttpServletRequest httpRequest) {
// 解析租户ID
Long tenantId = tenantResolver.resolveTenantId(
httpRequest,
request.getTenantId(),
request.getTenantCode()
);
log.info("用户 {} 登录,租户ID: {}", request.getUsername(), tenantId);
// 先查询用户信息以检查账户锁定状态
User user = userMapper.selectUserWithRolesAndPermissions(request.getUsername());
if (user == null) {
throw new BusinessException("用户名或密码错误");
}
// 验证用户是否属于指定租户
if (!user.getTenantId().equals(tenantId)) {
log.warn("用户 {} 不属于租户 {}, 实际租户: {}", request.getUsername(), tenantId, user.getTenantId());
throw new BusinessException("用户名或密码错误"); // 不暴露租户信息
}
// 检查账户是否被锁定
if (accountLockService.isAccountLocked(user)) {
long remainingSeconds = accountLockService.getRemainingLockTime(user);
long remainingMinutes = remainingSeconds / 60;
log.warn("用户 {} 账户已被锁定,剩余时间: {} 分钟", request.getUsername(), remainingMinutes);
throw new BusinessException(String.format("账户已被锁定,请在 %d 分钟后重试", remainingMinutes));
}
// 进行密码认证
Authentication authentication;
try {
authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
} catch (Exception e) {
// 认证失败,记录失败次数
accountLockService.recordLoginFailure(user);
log.warn("用户 {} 登录失败: {}", request.getUsername(), e.getMessage());
// 检查是否刚刚被锁定
if (accountLockService.isAccountLocked(user)) {
long remainingMinutes = accountLockService.getRemainingLockTime(user) / 60;
throw new BusinessException(String.format("登录失败次数过多,账户已被锁定 %d 分钟", remainingMinutes));
}
throw new BusinessException("用户名或密码错误");
}
// 检查邮箱是否已验证(如果启用了邮箱验证功能)
if (requireEmailVerification && user.getEmail() != null && user.getEmailVerified() == 0) {
log.warn("用户 {} 尝试登录但邮箱未验证", request.getUsername());
throw new BusinessException("请先验证您的邮箱地址后再登录。如未收到验证邮件,可申请重新发送。");
}
// 登录成功,重置失败次数
accountLockService.resetLoginFailureCount(user);
// 更新最后登录时间
user.setLastLoginTime(LocalDateTime.now());
userMapper.updateById(user);
// 缓存用户权限(登录时预热缓存)
permissionCacheService.cacheUserPermissions(user.getId(), user.getTenantId(), user);
log.debug("已缓存用户权限: userId={}, tenantId={}", user.getId(), user.getTenantId());
// 生成JWT token,包含租户ID和用户ID
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
if (user.getTenantId() != null) {
claims.put("tenantId", user.getTenantId());
}
String token = jwtUtil.generateToken(authentication.getName(), claims);
// 生成 Refresh Token
String refreshToken = refreshTokenService.createRefreshToken(user);
// 构建响应
return LoginResponse.builder()
.token(token)
.refreshToken(refreshToken)
.tokenType("Bearer")
.userId(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.roles(getRoleCodes(user))
.permissions(getPermissionCodes(user))
.build();
}
/**
* 用户注册
*/
@Transactional
public void register(RegisterRequest request, jakarta.servlet.http.HttpServletRequest httpRequest) {
// 解析租户ID
Long tenantId = tenantResolver.resolveTenantId(
httpRequest,
request.getTenantId(),
request.getTenantCode()
);
// 验证用户名是否已存在
Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
if (count > 0) {
throw new BusinessException("用户名已存在");
}
// 验证邮箱是否已存在
if (request.getEmail() != null) {
Long emailCount = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getEmail, request.getEmail()));
if (emailCount > 0) {
throw new BusinessException("邮箱已被使用");
}
}
// 验证两次密码是否一致
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new BusinessException("两次密码输入不一致");
}
// 创建用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEmail(request.getEmail());
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
user.setStatus(1);
user.setDeleted(0);
// 设置租户ID
user.setTenantId(tenantId);
userMapper.insert(user);
log.info("用户注册成功: {}, 租户ID: {}", request.getUsername(), tenantId);
// 自动分配默认角色
assignDefaultRole(user.getId(), tenantId);
// 发送邮箱验证邮件(如果启用且有邮箱)
if (requireEmailVerification && user.getEmail() != null) {
sendVerificationEmail(user);
}
}
/**
* 为新注册用户分配默认角色
*/
private void assignDefaultRole(Long userId, Long tenantId) {
// 检查是否启用自动分配
if (!autoAssignDefaultRole) {
log.debug("自动分配默认角色功能已禁用");
return;
}
try {
// 查找默认角色
Role defaultRole = roleMapper.selectOne(
new LambdaQueryWrapper<Role>()
.eq(Role::getCode, defaultRoleCode)
.eq(Role::getTenantId, tenantId)
);
if (defaultRole != null) {
// 分配角色给用户
boolean assigned = userRoleService.assignRoleToUser(userId, defaultRole.getId());
if (assigned) {
log.info("已为新用户自动分配默认角色: userId={}, roleId={}, roleCode={}, tenantId={}",
userId, defaultRole.getId(), defaultRoleCode, tenantId);
}
} else {
log.warn("未找到默认角色 {},无法为新用户分配角色: userId={}, tenantId={}",
defaultRoleCode, userId, tenantId);
}
} catch (Exception e) {
// 分配角色失败不影响注册流程,只记录日志
log.error("为新用户分配默认角色失败: userId={}, tenantId={}, roleCode={}, error={}",
userId, tenantId, defaultRoleCode, e.getMessage(), e);
}
}
/**
* 获取用户角色代码集合
*/
// private Set<String> getRoleCodes(User user) {
// if (user.getRoles() == null) {
// return Set.of();
// }
// return user.getRoles().stream()
// .map(Role::getCode)
// .collect(Collectors.toSet());
// }
/**
* 刷新 Access Token
*/
@Transactional
public LoginResponse refreshAccessToken(String refreshTokenStr) {
// 验证 Refresh Token
RefreshToken refreshToken = refreshTokenService.validateRefreshToken(refreshTokenStr);
// 获取用户信息
User user = userMapper.selectUserWithRolesAndPermissionsById(refreshToken.getUserId());
if (user == null) {
throw new BusinessException("用户不存在");
}
if (user.getStatus() == 0) {
throw new BusinessException("用户已被禁用");
}
// 标记旧的 Refresh Token 为已使用
refreshTokenService.markTokenAsUsed(refreshToken.getId());
// 缓存用户权限(刷新token时更新缓存)
permissionCacheService.cacheUserPermissions(user.getId(), user.getTenantId(), user);
// 生成新的 Access Token
Map<String, Object> claims = new HashMap<>();
if (user.getTenantId() != null) {
claims.put("tenantId", user.getTenantId());
}
String newAccessToken = jwtUtil.generateToken(user.getUsername(), claims);
// 生成新的 Refresh Token
String newRefreshToken = refreshTokenService.createRefreshToken(user);
// 构建响应
return LoginResponse.builder()
.token(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.userId(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.roles(getRoleCodes(user))
.permissions(getPermissionCodes(user))
.build();
}
/**
* 用户退出登录
*/
@Transactional
public void logout(String token, Long userId, Long tenantId) {
// 将 Access Token 加入黑名单
if (token != null) {
tokenBlacklistService.addToBlacklist(token);
}
// 删除用户的所有 Refresh Token
if (userId != null) {
refreshTokenService.deleteUserTokens(userId);
}
// 清除用户权限缓存
if (userId != null && tenantId != null) {
permissionCacheService.evictUserPermissions(userId, tenantId);
log.debug("已清除用户权限缓存: userId={}, tenantId={}", userId, tenantId);
}
log.info("用户 {} 已退出登录", userId);
}
/**
* 获取用户角色代码集合
*/
private Set<String> getRoleCodes(User user) {
if (user.getRoles() == null) {
return Set.of();
}
return user.getRoles().stream()
.map(Role::getCode)
.collect(Collectors.toSet());
}
/**
* 获取用户权限代码集合
*/
private Set<String> getPermissionCodes(User user) {
if (user.getRoles() == null) {
return Set.of();
}
return user.getRoles().stream()
.flatMap(role -> role.getPermissions() != null ? role.getPermissions().stream() : java.util.stream.Stream.empty())
.map(Permission::getCode)
.collect(Collectors.toSet());
}
/**
* 忘记密码 - 发送重置邮件
*
* @param request 忘记密码请求
*/
public void forgotPassword(ForgotPasswordRequest request) {
String email = request.getEmail();
Long tenantId = request.getTenantId();
log.info("Processing forgot password request for email: {} in tenant: {}", email, tenantId);
try {
// 设置租户上下文,让MyBatis Plus自动添加租户隔离条件
com.kurihada.auth.context.TenantContext.setTenantId(tenantId);
// 查询用户是否存在(租户ID由插件自动添加)
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getEmail, email);
queryWrapper.eq(User::getDeleted, 0); // 未删除的用户
User user = userMapper.selectOne(queryWrapper);
// 无论用户是否存在,都返回相同的消息(安全考虑,防止邮箱枚举)
if (user == null) {
log.warn("Password reset requested for non-existent email: {} in tenant: {}", email, tenantId);
// 不抛出异常,避免暴露用户是否存在
return;
}
// 检查用户状态
if (user.getStatus() != 1) {
log.warn("Password reset requested for disabled user: {} in tenant: {}", email, tenantId);
// 不抛出异常,避免暴露用户状态
return;
}
// 生成重置令牌(将邮箱和租户ID组合存储)
String resetToken = passwordResetTokenService.generateResetToken(email, tenantId);
// 构建重置链接
String resetUrl = "http://localhost:5173/reset-password"; // TODO: 从配置文件读取
// 发送重置邮件
try {
emailService.sendPasswordResetEmail(email, user.getUsername(), resetToken, resetUrl);
log.info("Password reset email sent successfully to: {} in tenant: {}", email, tenantId);
} catch (Exception e) {
log.error("Failed to send password reset email to: {} in tenant: {}", email, tenantId, e);
throw new BusinessException("发送重置邮件失败,请稍后重试");
}
} finally {
// 清除租户上下文,避免影响其他请求
com.kurihada.auth.context.TenantContext.clear();
}
}
/**
* 重置密码
*
* @param request 重置密码请求
*/
@Transactional
public void resetPassword(ResetPasswordRequest request) {
Long tenantId = request.getTenantId();
log.info("Processing password reset request for tenant: {}", tenantId);
// 验证两次密码是否一致
if (!request.getNewPassword().equals(request.getConfirmPassword())) {
throw new BusinessException("两次输入的密码不一致");
}
// 验证令牌并获取邮箱
String email = passwordResetTokenService.validateTokenAndGetEmail(request.getToken(), tenantId);
if (email == null) {
throw new BusinessException("重置令牌无效或已过期");
}
try {
// 设置租户上下文,让MyBatis Plus自动添加租户隔离条件
com.kurihada.auth.context.TenantContext.setTenantId(tenantId);
// 查询用户(租户ID由插件自动添加)
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getEmail, email);
queryWrapper.eq(User::getDeleted, 0);
User user = userMapper.selectOne(queryWrapper);
if (user == null) {
throw new BusinessException("用户不存在");
}
if (user.getStatus() != 1) {
throw new BusinessException("用户账号已被禁用");
}
// 记录用户信息用于调试
log.debug("Reset password for user - ID: {}, Username: {}, TenantId: {}",
user.getId(), user.getUsername(), user.getTenantId());
// 更新密码
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
user.setUpdateTime(LocalDateTime.now());
userMapper.updateById(user);
// 使令牌失效
passwordResetTokenService.invalidateToken(request.getToken());
// 清除该用户的所有权限缓存
permissionCacheService.evictUserPermissions(user.getId(), user.getTenantId());
log.info("User permission cache evicted for userId: {}, tenantId: {}",
user.getId(), user.getTenantId());
// 可选:将该用户的所有已登录token加入黑名单,强制重新登录
// 这里暂不实现,允许用户在密码重置后继续使用旧token直到过期
log.info("Password reset successfully for user: {} in tenant: {}", user.getUsername(), tenantId);
} finally {
// 清除租户上下文,避免影响其他请求
com.kurihada.auth.context.TenantContext.clear();
}
}
/**
* 发送邮箱验证邮件
*
* @param user 用户对象
*/
private void sendVerificationEmail(User user) {
try {
// 生成验证令牌
String verificationToken = emailVerificationTokenService.generateVerificationToken(
user.getEmail(),
user.getId(),
user.getTenantId()
);
// 构建验证链接
String verificationUrl = "http://localhost:5173/verify-email"; // TODO: 从配置文件读取
// 发送验证邮件
emailService.sendEmailVerification(
user.getEmail(),
user.getUsername(),
verificationToken,
verificationUrl
);
log.info("Email verification sent to: {} (userId: {}, tenantId: {})",
user.getEmail(), user.getId(), user.getTenantId());
} catch (Exception e) {
// 发送邮件失败不影响注册流程,只记录日志
log.error("Failed to send verification email to: {} (userId: {})",
user.getEmail(), user.getId(), e);
}
}
/**
* 验证邮箱
*
* @param token 验证令牌
* @param tenantId 租户ID
*/
@Transactional
public void verifyEmail(String token, Long tenantId) {
// 验证令牌并获取用户信息
Map<String, Object> tokenData = emailVerificationTokenService.validateTokenAndGetUserInfo(token);
if (tokenData == null) {
throw new BusinessException("验证令牌无效或已过期");
}
Long userId = convertToLong(tokenData.get("userId"));
Long storedTenantId = convertToLong(tokenData.get("tenantId"));
String email = (String) tokenData.get("email");
// 验证租户ID是否匹配
if (!tenantId.equals(storedTenantId)) {
log.warn("Tenant ID mismatch for email verification. Expected: {}, Got: {}", storedTenantId, tenantId);
throw new BusinessException("验证令牌无效");
}
try {
// 设置租户上下文
com.kurihada.auth.context.TenantContext.setTenantId(tenantId);
// 查询用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 检查邮箱是否匹配
if (!email.equals(user.getEmail())) {
throw new BusinessException("验证令牌与用户邮箱不匹配");
}
// 检查是否已验证
if (user.getEmailVerified() == 1) {
log.info("Email already verified for user: {} (userId: {})", user.getUsername(), userId);
return; // 已验证,直接返回
}
// 更新用户邮箱验证状态
user.setEmailVerified(1);
user.setEmailVerifiedAt(LocalDateTime.now());
userMapper.updateById(user);
// 使令牌失效
emailVerificationTokenService.invalidateToken(token);
log.info("Email verified successfully for user: {} (userId: {}, email: {})",
user.getUsername(), userId, email);
} finally {
com.kurihada.auth.context.TenantContext.clear();
}
}
/**
* 重新发送验证邮件
*
* @param email 用户邮箱
* @param tenantId 租户ID
*/
public void resendVerificationEmail(String email, Long tenantId) {
try {
com.kurihada.auth.context.TenantContext.setTenantId(tenantId);
// 查询用户
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getEmail, email);
queryWrapper.eq(User::getDeleted, 0);
User user = userMapper.selectOne(queryWrapper);
if (user == null) {
// 为了安全,不暴露用户是否存在
log.warn("Resend verification requested for non-existent email: {} in tenant: {}", email, tenantId);
return;
}
// 检查是否已验证
if (user.getEmailVerified() == 1) {
throw new BusinessException("邮箱已验证,无需重新发送");
}
// 发送验证邮件
sendVerificationEmail(user);
log.info("Verification email resent to: {} (userId: {})", email, user.getId());
} finally {
com.kurihada.auth.context.TenantContext.clear();
}
}
/**
* 辅助方法:将 Object 转换为 Long
*/
private Long convertToLong(Object value) {
if (value instanceof Integer) {
return ((Integer) value).longValue();
} else if (value instanceof Long) {
return (Long) value;
}
throw new BusinessException("Invalid token data");
}
}
@@ -0,0 +1,149 @@
package com.kurihada.auth.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
/**
* 邮件服务
* 负责发送各类邮件通知
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String from;
/**
* 发送密码重置邮件
*
* @param to 收件人邮箱
* @param username 用户名
* @param resetToken 重置令牌
* @param resetUrl 重置链接
*/
@Async
public void sendPasswordResetEmail(String to, String username, String resetToken, String resetUrl) {
try {
Context context = new Context();
context.setVariable("username", username);
context.setVariable("resetUrl", resetUrl + "?token=" + resetToken);
context.setVariable("validMinutes", 60); // 1小时
String htmlContent = templateEngine.process("password-reset-email", context);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject("密码重置请求 - Auth System");
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("Password reset email sent to: {}", to);
} catch (MessagingException e) {
log.error("Failed to send password reset email to: {}", to, e);
throw new RuntimeException("发送密码重置邮件失败", e);
}
}
/**
* 发送邮箱验证邮件
*
* @param to 收件人邮箱
* @param username 用户名
* @param verificationToken 验证令牌
* @param verificationUrl 验证链接
*/
@Async
public void sendEmailVerification(String to, String username, String verificationToken, String verificationUrl) {
try {
Context context = new Context();
context.setVariable("username", username);
context.setVariable("verificationUrl", verificationUrl + "?token=" + verificationToken);
context.setVariable("validHours", 24); // 24小时
String htmlContent = templateEngine.process("email-verification", context);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject("验证您的邮箱 - Auth System");
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("Email verification sent to: {}", to);
} catch (MessagingException e) {
log.error("Failed to send email verification to: {}", to, e);
throw new RuntimeException("发送验证邮件失败", e);
}
}
/**
* 发送纯文本邮件(备用方案)
*
* @param to 收件人
* @param subject 主题
* @param content 内容
*/
@Async
public void sendSimpleEmail(String to, String subject, String content) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, false);
mailSender.send(message);
log.info("Simple email sent to: {}", to);
} catch (MessagingException e) {
log.error("Failed to send simple email to: {}", to, e);
throw new RuntimeException("发送邮件失败", e);
}
}
/**
* 发送HTML邮件
*
* @param to 收件人
* @param subject 主题
* @param htmlContent HTML内容
*/
@Async
public void sendHtmlEmail(String to, String subject, String htmlContent) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("HTML email sent to: {}", to);
} catch (MessagingException e) {
log.error("Failed to send HTML email to: {}", to, e);
throw new RuntimeException("发送HTML邮件失败", e);
}
}
}
@@ -0,0 +1,110 @@
package com.kurihada.auth.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 邮箱验证Token服务
* 负责生成、验证和管理邮箱验证令牌
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailVerificationTokenService {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${email.verification-token-expiration:86400}") // 默认24小时
private long tokenExpirationSeconds;
private static final String VERIFICATION_TOKEN_PREFIX = "email_verification:";
private static final int TOKEN_LENGTH = 32;
/**
* 生成邮箱验证令牌
*
* @param email 用户邮箱
* @param userId 用户ID
* @param tenantId 租户ID
* @return 验证令牌
*/
public String generateVerificationToken(String email, Long userId, Long tenantId) {
// 生成安全的随机令牌
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[TOKEN_LENGTH];
random.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// 存储到Rediskey为tokenvalue为包含email、userId和tenantId的Map
String key = VERIFICATION_TOKEN_PREFIX + token;
Map<String, Object> tokenData = new HashMap<>();
tokenData.put("email", email);
tokenData.put("userId", userId);
tokenData.put("tenantId", tenantId);
redisTemplate.opsForValue().set(key, tokenData, tokenExpirationSeconds, TimeUnit.SECONDS);
log.info("Generated email verification token for email: {} (userId: {}, tenantId: {})",
email, userId, tenantId);
return token;
}
/**
* 验证令牌并获取用户信息
*
* @param token 验证令牌
* @return 包含email、userId和tenantId的Map,如果令牌无效则返回null
*/
public Map<String, Object> validateTokenAndGetUserInfo(String token) {
String key = VERIFICATION_TOKEN_PREFIX + token;
Object tokenDataObj = redisTemplate.opsForValue().get(key);
if (tokenDataObj == null) {
log.warn("Invalid or expired email verification token");
return null;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> tokenData = (Map<String, Object>) tokenDataObj;
log.debug("Validated email verification token for email: {}", tokenData.get("email"));
return tokenData;
} catch (ClassCastException e) {
log.error("Error parsing token data", e);
return null;
}
}
/**
* 使令牌失效(用于邮箱验证成功后)
*
* @param token 验证令牌
*/
public void invalidateToken(String token) {
String key = VERIFICATION_TOKEN_PREFIX + token;
redisTemplate.delete(key);
log.info("Invalidated email verification token");
}
/**
* 检查用户是否有未使用的验证令牌
*
* @param userId 用户ID
* @return true表示存在活跃令牌
*/
public boolean hasActiveVerificationToken(Long userId) {
// 简化实现:直接返回false,允许重新发送验证邮件
// 实际应用中可以通过用户ID索引来查询
return false;
}
}
@@ -0,0 +1,128 @@
package com.kurihada.auth.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 密码重置Token服务
* 负责生成、验证和管理密码重置令牌
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordResetTokenService {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${password.reset-token-expiration:3600}")
private long tokenExpirationSeconds;
private static final String RESET_TOKEN_PREFIX = "password_reset:";
private static final int TOKEN_LENGTH = 32;
/**
* 生成密码重置令牌
*
* @param email 用户邮箱
* @param tenantId 租户ID
* @return 重置令牌
*/
public String generateResetToken(String email, Long tenantId) {
// 生成安全的随机令牌
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[TOKEN_LENGTH];
random.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// 存储到Rediskey为tokenvalue为包含email和tenantId的Map
String key = RESET_TOKEN_PREFIX + token;
Map<String, Object> tokenData = new HashMap<>();
tokenData.put("email", email);
tokenData.put("tenantId", tenantId);
redisTemplate.opsForValue().set(key, tokenData, tokenExpirationSeconds, TimeUnit.SECONDS);
log.info("Generated password reset token for email: {} in tenant: {}", email, tenantId);
return token;
}
/**
* 验证重置令牌并获取关联的邮箱
*
* @param token 重置令牌
* @param tenantId 租户ID(用于验证租户匹配)
* @return 关联的邮箱,如果令牌无效或租户不匹配则返回null
*/
public String validateTokenAndGetEmail(String token, Long tenantId) {
String key = RESET_TOKEN_PREFIX + token;
Object tokenDataObj = redisTemplate.opsForValue().get(key);
if (tokenDataObj == null) {
log.warn("Invalid or expired password reset token");
return null;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> tokenData = (Map<String, Object>) tokenDataObj;
String email = (String) tokenData.get("email");
Object storedTenantIdObj = tokenData.get("tenantId");
// 将存储的 tenantId 转换为 Long 类型
Long storedTenantId = null;
if (storedTenantIdObj instanceof Integer) {
storedTenantId = ((Integer) storedTenantIdObj).longValue();
} else if (storedTenantIdObj instanceof Long) {
storedTenantId = (Long) storedTenantIdObj;
}
// 验证租户ID是否匹配
if (!tenantId.equals(storedTenantId)) {
log.warn("Tenant ID mismatch for password reset token. Expected: {}, Got: {}",
storedTenantId, tenantId);
return null;
}
log.debug("Validated password reset token for email: {} in tenant: {}", email, tenantId);
return email;
} catch (ClassCastException e) {
log.error("Error parsing token data", e);
return null;
}
}
/**
* 使令牌失效(用于密码重置成功后)
*
* @param token 重置令牌
*/
public void invalidateToken(String token) {
String key = RESET_TOKEN_PREFIX + token;
redisTemplate.delete(key);
log.info("Invalidated password reset token");
}
/**
* 检查邮箱在指定租户下是否有活跃的重置令牌
*
* @param email 用户邮箱
* @param tenantId 租户ID
* @return true表示存在活跃令牌
*/
public boolean hasActiveResetToken(String email, Long tenantId) {
// 这里可以通过扫描Redis键来实现,但为了性能考虑,
// 我们可以在生成token时额外存储一个反向映射
// 简化实现:直接返回false,允许生成新令牌
return false;
}
}
@@ -0,0 +1,94 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.UserPermissionCache;
import com.kurihada.auth.entity.User;
/**
* 权限缓存服务接口
*/
public interface PermissionCacheService {
/**
* 获取用户权限缓存
* @param userId 用户ID
* @param tenantId 租户ID
* @return 用户权限缓存
*/
UserPermissionCache getUserPermissions(Long userId, Long tenantId);
/**
* 缓存用户权限
* @param userId 用户ID
* @param tenantId 租户ID
* @param user 用户对象(包含角色和权限)
*/
void cacheUserPermissions(Long userId, Long tenantId, User user);
/**
* 删除用户权限缓存
* @param userId 用户ID
* @param tenantId 租户ID
*/
void evictUserPermissions(Long userId, Long tenantId);
/**
* 删除租户下所有用户的权限缓存
* @param tenantId 租户ID
*/
void evictTenantPermissions(Long tenantId);
/**
* 删除拥有指定角色的所有用户的权限缓存
* @param roleId 角色ID
* @param tenantId 租户ID
*/
void evictRoleRelatedPermissions(Long roleId, Long tenantId);
/**
* 检查用户是否拥有指定权限
* @param userId 用户ID
* @param tenantId 租户ID
* @param permissionCode 权限代码
* @return true-有权限,false-无权限
*/
boolean hasPermission(Long userId, Long tenantId, String permissionCode);
/**
* 检查用户是否拥有指定角色
* @param userId 用户ID
* @param tenantId 租户ID
* @param roleCode 角色代码
* @return true-有角色,false-无角色
*/
boolean hasRole(Long userId, Long tenantId, String roleCode);
/**
* 检查用户是否有权访问指定资源
* @param userId 用户ID
* @param tenantId 租户ID
* @param resource 资源路径
* @return true-有权限,false-无权限
*/
boolean hasResource(Long userId, Long tenantId, String resource);
/**
* 预热用户权限缓存
* @param userId 用户ID
* @param tenantId 租户ID
*/
void warmUpUserPermissions(Long userId, Long tenantId);
/**
* 清空所有权限缓存
*/
void clearAllPermissionCache();
/**
* 从缓存构建 UserDetails(用于 JWT 认证过滤器)
* @param userId 用户ID
* @param tenantId 租户ID
* @param username 用户名
* @return UserDetails 对象,如果缓存未命中则返回null
*/
org.springframework.security.core.userdetails.UserDetails buildUserDetailsFromCache(Long userId, Long tenantId, String username);
}
@@ -0,0 +1,51 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.CreatePermissionRequest;
import com.kurihada.auth.dto.PageRequest;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.PermissionDTO;
import com.kurihada.auth.dto.PermissionTreeNode;
import com.kurihada.auth.dto.UpdatePermissionRequest;
import java.util.List;
/**
* 权限服务接口
*/
public interface PermissionService {
/**
* 分页查询权限
*/
PageResult<PermissionDTO> getPermissionsPage(PageRequest pageRequest);
/**
* 根据ID获取权限
*/
PermissionDTO getPermissionById(Long id);
/**
* 创建新权限
*/
PermissionDTO createPermission(CreatePermissionRequest request);
/**
* 更新权限
*/
PermissionDTO updatePermission(Long id, UpdatePermissionRequest request);
/**
* 删除权限
*/
void deletePermission(Long id);
/**
* 切换权限状态
*/
void togglePermissionStatus(Long id);
/**
* 获取权限树形结构
*/
List<PermissionTreeNode> getPermissionTree();
}
@@ -0,0 +1,102 @@
package com.kurihada.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kurihada.auth.entity.RefreshToken;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.RefreshTokenMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Refresh Token 服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenMapper refreshTokenMapper;
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration;
/**
* 创建 Refresh Token
*/
@Transactional
public String createRefreshToken(User user) {
// 生成唯一的 refresh token
String token = UUID.randomUUID().toString().replace("-", "");
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUserId(user.getId());
refreshToken.setToken(token);
refreshToken.setTenantId(user.getTenantId());
refreshToken.setExpireTime(LocalDateTime.now().plusSeconds(refreshExpiration / 1000));
refreshToken.setUsed(false);
refreshTokenMapper.insert(refreshToken);
log.info("为用户 {} 创建 Refresh Token", user.getUsername());
return token;
}
/**
* 验证 Refresh Token
*/
public RefreshToken validateRefreshToken(String token) {
LambdaQueryWrapper<RefreshToken> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(RefreshToken::getToken, token)
.eq(RefreshToken::getUsed, false)
.gt(RefreshToken::getExpireTime, LocalDateTime.now());
RefreshToken refreshToken = refreshTokenMapper.selectOne(queryWrapper);
if (refreshToken == null) {
throw new BusinessException("Refresh Token 无效或已过期");
}
return refreshToken;
}
/**
* 标记 Refresh Token 为已使用
*/
@Transactional
public void markTokenAsUsed(Long tokenId) {
RefreshToken refreshToken = refreshTokenMapper.selectById(tokenId);
if (refreshToken != null) {
refreshToken.setUsed(true);
refreshTokenMapper.updateById(refreshToken);
}
}
/**
* 删除用户的所有 Refresh Token(用于退出登录)
*/
@Transactional
public void deleteUserTokens(Long userId) {
LambdaQueryWrapper<RefreshToken> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(RefreshToken::getUserId, userId);
refreshTokenMapper.delete(queryWrapper);
log.info("删除用户 {} 的所有 Refresh Token", userId);
}
/**
* 清理过期的 Refresh Token(定时任务)
*/
@Transactional
public void cleanExpiredTokens() {
LambdaQueryWrapper<RefreshToken> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.lt(RefreshToken::getExpireTime, LocalDateTime.now());
int count = refreshTokenMapper.delete(queryWrapper);
log.info("清理了 {} 个过期的 Refresh Token", count);
}
}
@@ -0,0 +1,98 @@
package com.kurihada.auth.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.RolePermission;
import java.util.List;
/**
* 角色权限关联服务接口
*/
public interface RolePermissionService extends IService<RolePermission> {
/**
* 为角色分配权限
*
* @param roleId 角色ID
* @param permissionId 权限ID
* @return 是否成功
*/
boolean assignPermissionToRole(Long roleId, Long permissionId);
/**
* 批量为角色分配权限
*
* @param roleId 角色ID
* @param permissionIds 权限ID列表
* @return 是否成功
*/
boolean assignPermissionsToRole(Long roleId, List<Long> permissionIds);
/**
* 移除角色的指定权限
*
* @param roleId 角色ID
* @param permissionId 权限ID
* @return 是否成功
*/
boolean removePermissionFromRole(Long roleId, Long permissionId);
/**
* 移除角色的所有权限
*
* @param roleId 角色ID
* @return 是否成功
*/
boolean removeAllPermissionsFromRole(Long roleId);
/**
* 移除权限的所有角色关联
*
* @param permissionId 权限ID
* @return 是否成功
*/
boolean removeAllRolesFromPermission(Long permissionId);
/**
* 获取角色的所有权限ID列表
*
* @param roleId 角色ID
* @return 权限ID列表
*/
List<Long> getPermissionIdsByRoleId(Long roleId);
/**
* 获取角色的所有权限详情列表
*
* @param roleId 角色ID
* @return 权限详情列表
*/
List<Permission> getPermissionsByRoleId(Long roleId);
/**
* 获取拥有指定权限的所有角色ID列表
*
* @param permissionId 权限ID
* @return 角色ID列表
*/
List<Long> getRoleIdsByPermissionId(Long permissionId);
/**
* 检查角色是否拥有指定权限
*
* @param roleId 角色ID
* @param permissionId 权限ID
* @return 是否拥有
*/
boolean hasPermission(Long roleId, Long permissionId);
/**
* 更新角色的权限(先删除所有旧权限,再添加新权限)
*
* @param roleId 角色ID
* @param permissionIds 新的权限ID列表
* @return 是否成功
*/
boolean updateRolePermissions(Long roleId, List<Long> permissionIds);
}
@@ -0,0 +1,45 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.CreateRoleRequest;
import com.kurihada.auth.dto.PageRequest;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.RoleDTO;
import com.kurihada.auth.dto.UpdateRoleRequest;
import java.util.List;
/**
* 角色服务接口
*/
public interface RoleService {
/**
* 分页查询角色
*/
PageResult<RoleDTO> getRolesPage(PageRequest pageRequest);
/**
* 根据ID获取角色
*/
RoleDTO getRoleById(Long id);
/**
* 创建新角色
*/
RoleDTO createRole(CreateRoleRequest request);
/**
* 更新角色
*/
RoleDTO updateRole(Long id, UpdateRoleRequest request);
/**
* 删除角色
*/
void deleteRole(Long id);
/**
* 切换角色状态
*/
void toggleRoleStatus(Long id);
}
@@ -0,0 +1,71 @@
package com.kurihada.auth.service;
import com.kurihada.auth.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* Token 黑名单服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, Object> redisTemplate;
private final JwtUtil jwtUtil;
private static final String BLACKLIST_PREFIX = "token:blacklist:";
/**
* 将 token 加入黑名单
*/
public void addToBlacklist(String token) {
try {
// 获取 token 的过期时间
Date expirationDate = jwtUtil.getExpirationDateFromToken(token);
long expiration = expirationDate.getTime() - System.currentTimeMillis();
if (expiration > 0) {
// 将 token 加入黑名单,过期时间与 token 本身的过期时间一致
String key = BLACKLIST_PREFIX + token;
redisTemplate.opsForValue().set(key, "1", expiration, TimeUnit.MILLISECONDS);
log.info("Token 已加入黑名单,剩余有效期: {} 毫秒", expiration);
}
} catch (Exception e) {
log.error("添加 token 到黑名单失败: {}", e.getMessage());
}
}
/**
* 检查 token 是否在黑名单中
*/
public boolean isBlacklisted(String token) {
try {
String key = BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
log.error("检查 token 黑名单状态失败: {}", e.getMessage());
// 发生异常时,为了安全起见,返回 false(允许通过)
return false;
}
}
/**
* 从黑名单中移除 token(通常不需要,因为会自动过期)
*/
public void removeFromBlacklist(String token) {
try {
String key = BLACKLIST_PREFIX + token;
redisTemplate.delete(key);
log.info("Token 已从黑名单中移除");
} catch (Exception e) {
log.error("从黑名单移除 token 失败: {}", e.getMessage());
}
}
}
@@ -0,0 +1,141 @@
package com.kurihada.auth.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.entity.UserRole;
import java.util.List;
/**
* 用户角色关联服务接口
*/
public interface UserRoleService extends IService<UserRole> {
/**
* 为用户分配角色
*
* @param userId 用户ID
* @param roleId 角色ID
* @return 是否成功
*/
boolean assignRoleToUser(Long userId, Long roleId);
/**
* 批量为用户分配角色
*
* @param userId 用户ID
* @param roleIds 角色ID列表
* @return 是否成功
*/
boolean assignRolesToUser(Long userId, List<Long> roleIds);
/**
* 批量为角色分配用户
*
* @param roleId 角色ID
* @param userIds 用户ID列表
* @return 是否成功
*/
boolean assignUsersToRole(Long roleId, List<Long> userIds);
/**
* 移除用户的指定角色
*
* @param userId 用户ID
* @param roleId 角色ID
* @return 是否成功
*/
boolean removeRoleFromUser(Long userId, Long roleId);
/**
* 移除用户的所有角色
*
* @param userId 用户ID
* @return 是否成功
*/
boolean removeAllRolesFromUser(Long userId);
/**
* 移除角色的所有用户关联
*
* @param roleId 角色ID
* @return 是否成功
*/
boolean removeAllUsersFromRole(Long roleId);
/**
* 获取用户的所有角色ID列表
*
* @param userId 用户ID
* @return 角色ID列表
*/
List<Long> getRoleIdsByUserId(Long userId);
/**
* 获取用户的所有角色详情列表
*
* @param userId 用户ID
* @return 角色详情列表
*/
List<Role> getRolesByUserId(Long userId);
/**
* 获取拥有指定角色的所有用户ID列表
*
* @param roleId 角色ID
* @return 用户ID列表
*/
List<Long> getUserIdsByRoleId(Long roleId);
/**
* 获取拥有指定角色的所有用户详情列表
*
* @param roleId 角色ID
* @return 用户详情列表
*/
List<User> getUsersByRoleId(Long roleId);
/**
* 检查用户是否拥有指定角色
*
* @param userId 用户ID
* @param roleId 角色ID
* @return 是否拥有
*/
boolean hasRole(Long userId, Long roleId);
/**
* 检查用户是否拥有指定角色代码
*
* @param userId 用户ID
* @param roleCode 角色代码
* @return 是否拥有
*/
boolean hasRoleByCode(Long userId, String roleCode);
/**
* 更新用户的角色(先删除所有旧角色,再添加新角色)
*
* @param userId 用户ID
* @param roleIds 新的角色ID列表
* @return 是否成功
*/
boolean updateUserRoles(Long userId, List<Long> roleIds);
/**
* 统计角色的用户数量
*
* @param roleId 角色ID
* @return 用户数量
*/
int countUsersByRoleId(Long roleId);
/**
* 统计用户的角色数量
*
* @param userId 用户ID
* @return 角色数量
*/
int countRolesByUserId(Long userId);
}
@@ -0,0 +1,466 @@
package com.kurihada.auth.service;
import com.kurihada.auth.dto.ChangePasswordRequest;
import com.kurihada.auth.dto.PageRequest;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.PermissionDTO;
import com.kurihada.auth.dto.PermissionTreeNode;
import com.kurihada.auth.dto.UserDTO;
import com.kurihada.auth.dto.UpdateUserRequest;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.PermissionMapper;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 用户服务类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final PasswordEncoder passwordEncoder;
private final PermissionCacheService permissionCacheService;
/**
* 获取当前登录用户信息
*/
public UserDTO getCurrentUser() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
// 使用优化后的联表查询
User user = userMapper.selectUserWithRolesAndPermissions(username);
if (user == null) {
throw new BusinessException("用户不存在");
}
return convertToDTO(user);
}
/**
* 根据ID获取用户信息
*/
public UserDTO getUserById(Long id) {
// 使用优化后的联表查询
User user = userMapper.selectUserWithRolesAndPermissionsById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
return convertToDTO(user);
}
/**
* 分页查询用户列表
*/
public PageResult<UserDTO> getUsersPage(PageRequest pageRequest) {
// 构建查询条件
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getDeleted, 0);
// 搜索条件
if (StringUtils.hasText(pageRequest.getKeyword())) {
queryWrapper.and(wrapper -> wrapper
.like(User::getUsername, pageRequest.getKeyword())
.or()
.like(User::getRealName, pageRequest.getKeyword())
.or()
.like(User::getEmail, pageRequest.getKeyword())
.or()
.like(User::getPhone, pageRequest.getKeyword())
);
}
// 排序
if (StringUtils.hasText(pageRequest.getSortBy())) {
boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection());
switch (pageRequest.getSortBy()) {
case "username" -> queryWrapper.orderBy(true, isAsc, User::getUsername);
case "realName" -> queryWrapper.orderBy(true, isAsc, User::getRealName);
case "email" -> queryWrapper.orderBy(true, isAsc, User::getEmail);
case "status" -> queryWrapper.orderBy(true, isAsc, User::getStatus);
case "createTime" -> queryWrapper.orderBy(true, isAsc, User::getCreateTime);
case "lastLoginTime" -> queryWrapper.orderBy(true, isAsc, User::getLastLoginTime);
default -> queryWrapper.orderByDesc(User::getCreateTime);
}
} else {
queryWrapper.orderByDesc(User::getCreateTime);
}
// 查询总数
long total = userMapper.selectCount(queryWrapper);
// 查询分页数据
queryWrapper.last("LIMIT " + pageRequest.getOffset() + ", " + pageRequest.getLimit());
List<User> users = userMapper.selectList(queryWrapper);
// 转换为DTO
List<UserDTO> userDTOs = users.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
return PageResult.of(userDTOs, total, pageRequest.getPage(), pageRequest.getSize());
}
/**
* 删除用户(逻辑删除)
*/
public void deleteUser(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
// MyBatis Plus的逻辑删除会自动处理
userMapper.deleteById(id);
log.info("用户已删除: {}", user.getUsername());
}
/**
* 更新用户信息(管理员操作)
*/
@Transactional(rollbackFor = Exception.class)
public UserDTO updateUser(Long id, com.kurihada.auth.dto.UpdateUserRequest request) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 更新用户信息
if (request.getRealName() != null) {
user.setRealName(request.getRealName());
}
if (request.getEmail() != null) {
// 检查邮箱是否已被其他用户使用
Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getEmail, request.getEmail())
.ne(User::getId, id));
if (count > 0) {
throw new BusinessException("邮箱已被其他用户使用");
}
user.setEmail(request.getEmail());
}
if (request.getPhone() != null) {
// 检查手机号是否已被其他用户使用
Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getPhone, request.getPhone())
.ne(User::getId, id));
if (count > 0) {
throw new BusinessException("手机号已被其他用户使用");
}
user.setPhone(request.getPhone());
}
if (request.getGender() != null) {
user.setGender(request.getGender());
}
if (request.getAvatar() != null) {
user.setAvatar(request.getAvatar());
}
userMapper.updateById(user);
log.info("用户信息已更新: {}", user.getUsername());
// 返回更新后的用户信息
User updatedUser = userMapper.selectUserWithRolesAndPermissions(user.getUsername());
return convertToDTO(updatedUser);
}
/**
* 更新当前用户信息(用户自己操作)
*/
@Transactional(rollbackFor = Exception.class)
public UserDTO updateCurrentUser(com.kurihada.auth.dto.UpdateUserRequest request) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
if (user == null) {
throw new BusinessException("用户不存在");
}
// 更新用户信息
if (request.getRealName() != null) {
user.setRealName(request.getRealName());
}
if (request.getEmail() != null) {
// 检查邮箱是否已被其他用户使用
Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getEmail, request.getEmail())
.ne(User::getId, user.getId()));
if (count > 0) {
throw new BusinessException("邮箱已被其他用户使用");
}
user.setEmail(request.getEmail());
}
if (request.getPhone() != null) {
// 检查手机号是否已被其他用户使用
Long count = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getPhone, request.getPhone())
.ne(User::getId, user.getId()));
if (count > 0) {
throw new BusinessException("手机号已被其他用户使用");
}
user.setPhone(request.getPhone());
}
if (request.getGender() != null) {
user.setGender(request.getGender());
}
if (request.getAvatar() != null) {
user.setAvatar(request.getAvatar());
}
userMapper.updateById(user);
log.info("用户 {} 更新了自己的信息", user.getUsername());
// 返回更新后的用户信息
User updatedUser = userMapper.selectUserWithRolesAndPermissions(user.getUsername());
return convertToDTO(updatedUser);
}
/**
* 启用/禁用用户
*/
public void toggleUserStatus(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
user.setStatus(user.getStatus() == 1 ? 0 : 1);
userMapper.updateById(user);
log.info("用户状态已更新: {}, 新状态: {}", user.getUsername(), user.getStatus());
}
/**
* 转换为DTO
*/
private UserDTO convertToDTO(User user) {
// 角色和权限已经通过联表查询加载,无需再次查询
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.email(user.getEmail())
.phone(user.getPhone())
.gender(user.getGender())
.avatar(user.getAvatar())
.status(user.getStatus())
.lastLoginTime(user.getLastLoginTime())
.createTime(user.getCreateTime())
.roles(getRoleCodes(user))
.permissions(getPermissionCodes(user))
.build();
}
/**
* 获取用户角色代码集合
*/
private Set<String> getRoleCodes(User user) {
if (user.getRoles() == null) {
return Set.of();
}
return user.getRoles().stream()
.map(Role::getCode)
.collect(Collectors.toSet());
}
/**
* 获取用户权限代码集合
*/
private Set<String> getPermissionCodes(User user) {
if (user.getRoles() == null) {
return Set.of();
}
return user.getRoles().stream()
.flatMap(role -> role.getPermissions() != null ? role.getPermissions().stream() : java.util.stream.Stream.empty())
.map(Permission::getCode)
.collect(Collectors.toSet());
}
/**
* 修改当前用户密码
*/
@Transactional(rollbackFor = Exception.class)
public void changePassword(ChangePasswordRequest request) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
if (user == null) {
throw new BusinessException("用户不存在");
}
// 验证旧密码
if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) {
throw new BusinessException("旧密码不正确");
}
// 验证新密码不能与旧密码相同
if (request.getOldPassword().equals(request.getNewPassword())) {
throw new BusinessException("新密码不能与旧密码相同");
}
// 更新密码
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userMapper.updateById(user);
log.info("用户 {} 已修改密码", username);
// 清除用户权限缓存(可选,因为密码修改不影响权限)
if (user.getTenantId() != null) {
permissionCacheService.evictUserPermissions(user.getId(), user.getTenantId());
}
}
/**
* 获取当前用户的所有权限
*/
public List<PermissionDTO> getCurrentUserPermissions() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userMapper.selectUserWithRolesAndPermissions(username);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 获取用户所有角色的权限
Set<Long> permissionIds = new HashSet<>();
if (user.getRoles() != null) {
for (Role role : user.getRoles()) {
List<Long> rolePermissionIds = rolePermissionMapper.selectPermissionIdsByRoleId(role.getId());
permissionIds.addAll(rolePermissionIds);
}
}
if (permissionIds.isEmpty()) {
return List.of();
}
// 查询权限详情
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
return permissions.stream()
.filter(p -> p.getStatus() == 1) // 只返回启用的权限
.map(this::convertPermissionToDTO)
.collect(Collectors.toList());
}
/**
* 获取当前用户的菜单树
*/
public List<PermissionTreeNode> getCurrentUserMenus() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userMapper.selectUserWithRolesAndPermissions(username);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 获取用户所有角色的权限
Set<Long> permissionIds = new HashSet<>();
if (user.getRoles() != null) {
for (Role role : user.getRoles()) {
List<Long> rolePermissionIds = rolePermissionMapper.selectPermissionIdsByRoleId(role.getId());
permissionIds.addAll(rolePermissionIds);
}
}
if (permissionIds.isEmpty()) {
return List.of();
}
// 查询权限详情,只查询菜单类型的权限
List<Permission> permissions = permissionMapper.selectList(
new LambdaQueryWrapper<Permission>()
.in(Permission::getId, permissionIds)
.eq(Permission::getType, "menu")
.eq(Permission::getStatus, 1)
.orderByAsc(Permission::getSort)
);
// 构建树形结构
Map<Long, PermissionTreeNode> nodeMap = new HashMap<>();
for (Permission permission : permissions) {
PermissionTreeNode node = convertPermissionToTreeNode(permission);
nodeMap.put(permission.getId(), node);
}
// 构建树
List<PermissionTreeNode> rootNodes = new ArrayList<>();
for (PermissionTreeNode node : nodeMap.values()) {
if (node.getParentId() == null || node.getParentId() == 0) {
rootNodes.add(node);
} else {
PermissionTreeNode parent = nodeMap.get(node.getParentId());
if (parent != null) {
parent.getChildren().add(node);
} else {
rootNodes.add(node);
}
}
}
return rootNodes;
}
/**
* 转换权限为DTO
*/
private PermissionDTO convertPermissionToDTO(Permission permission) {
return PermissionDTO.builder()
.id(permission.getId())
.name(permission.getName())
.code(permission.getCode())
.tenantId(permission.getTenantId())
.description(permission.getDescription())
.type(permission.getType())
.resource(permission.getResource())
.parentId(permission.getParentId())
.sort(permission.getSort())
.status(permission.getStatus())
.createTime(permission.getCreateTime())
.updateTime(permission.getUpdateTime())
.build();
}
/**
* 转换权限为树节点
*/
private PermissionTreeNode convertPermissionToTreeNode(Permission permission) {
return PermissionTreeNode.builder()
.id(permission.getId())
.name(permission.getName())
.code(permission.getCode())
.tenantId(permission.getTenantId())
.type(permission.getType())
.resource(permission.getResource())
.parentId(permission.getParentId())
.sort(permission.getSort())
.status(permission.getStatus())
.createTime(permission.getCreateTime())
.updateTime(permission.getUpdateTime())
.children(new ArrayList<>())
.build();
}
}
@@ -0,0 +1,146 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.service.AccountLockService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
/**
* 账户锁定服务实现类
* 功能:管理账户登录失败次数和锁定状态
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountLockServiceImpl implements AccountLockService {
private final UserMapper userMapper;
/**
* 最大登录失败次数(默认5次)
*/
@Value("${account-lock.max-failed-attempts:5}")
private int maxFailedAttempts;
/**
* 账户锁定时长(分钟,默认30分钟)
*/
@Value("${account-lock.lock-duration-minutes:30}")
private int lockDurationMinutes;
@Override
public boolean isAccountLocked(User user) {
if (user.getAccountLockedAt() == null || user.getAccountUnlockAt() == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime unlockTime = user.getAccountUnlockAt();
// 如果当前时间超过解锁时间,自动解锁
if (now.isAfter(unlockTime)) {
log.info("账户锁定时间已过,自动解锁用户: {}", user.getUsername());
unlockAccount(user);
return false;
}
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recordLoginFailure(User user) {
int currentFailedCount = user.getLoginFailedCount() != null ? user.getLoginFailedCount() : 0;
int newFailedCount = currentFailedCount + 1;
log.warn("用户 {} 登录失败,失败次数: {}/{}", user.getUsername(), newFailedCount, maxFailedAttempts);
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(User::getId, user.getId());
if (newFailedCount >= maxFailedAttempts) {
// 达到最大失败次数,锁定账户
LocalDateTime lockedAt = LocalDateTime.now();
LocalDateTime unlockAt = lockedAt.plusMinutes(lockDurationMinutes);
updateWrapper.set(User::getLoginFailedCount, newFailedCount)
.set(User::getAccountLockedAt, lockedAt)
.set(User::getAccountUnlockAt, unlockAt);
log.warn("用户 {} 登录失败次数达到 {} 次,账户已锁定,解锁时间: {}",
user.getUsername(), maxFailedAttempts, unlockAt);
} else {
// 只增加失败次数
updateWrapper.set(User::getLoginFailedCount, newFailedCount);
}
userMapper.update(null, updateWrapper);
// 更新当前对象的状态
user.setLoginFailedCount(newFailedCount);
if (newFailedCount >= maxFailedAttempts) {
user.setAccountLockedAt(LocalDateTime.now());
user.setAccountUnlockAt(LocalDateTime.now().plusMinutes(lockDurationMinutes));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resetLoginFailureCount(User user) {
if (user.getLoginFailedCount() != null && user.getLoginFailedCount() > 0) {
log.info("重置用户 {} 的登录失败次数", user.getUsername());
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(User::getId, user.getId())
.set(User::getLoginFailedCount, 0)
.set(User::getAccountLockedAt, null)
.set(User::getAccountUnlockAt, null);
userMapper.update(null, updateWrapper);
// 更新当前对象的状态
user.setLoginFailedCount(0);
user.setAccountLockedAt(null);
user.setAccountUnlockAt(null);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unlockAccount(User user) {
log.info("解锁用户账户: {}", user.getUsername());
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(User::getId, user.getId())
.set(User::getLoginFailedCount, 0)
.set(User::getAccountLockedAt, null)
.set(User::getAccountUnlockAt, null);
userMapper.update(null, updateWrapper);
// 更新当前对象的状态
user.setLoginFailedCount(0);
user.setAccountLockedAt(null);
user.setAccountUnlockAt(null);
}
@Override
public long getRemainingLockTime(User user) {
if (!isAccountLocked(user)) {
return 0;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime unlockTime = user.getAccountUnlockAt();
return ChronoUnit.SECONDS.between(now, unlockTime);
}
}
@@ -0,0 +1,232 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.kurihada.auth.dto.AuditLogQueryRequest;
import com.kurihada.auth.dto.AuditLogResponse;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.entity.AuditLog;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.AuditLogMapper;
import com.kurihada.auth.service.AuditLogService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 审计日志服务实现
*/
@Slf4j
@Service
public class AuditLogServiceImpl implements AuditLogService {
@Autowired
private AuditLogMapper auditLogMapper;
@Override
@Async // 异步记录日志,不影响主流程性能
public void log(AuditLog auditLog) {
try {
auditLogMapper.insert(auditLog);
log.debug("审计日志记录成功: {}", auditLog);
} catch (Exception e) {
// 记录日志失败不应该影响主业务流程
log.error("审计日志记录失败: {}", auditLog, e);
}
}
@Override
public void logSuccess(String actionType, Long userId, String username, Long tenantId,
String resourceType, String resourceId, String details,
HttpServletRequest request) {
AuditLog auditLog = buildAuditLog(actionType, userId, username, tenantId,
resourceType, resourceId, details, "SUCCESS", null, request);
log(auditLog);
}
@Override
public void logFailure(String actionType, Long userId, String username, Long tenantId,
String resourceType, String resourceId, String details,
String errorMessage, HttpServletRequest request) {
AuditLog auditLog = buildAuditLog(actionType, userId, username, tenantId,
resourceType, resourceId, details, "FAILURE", errorMessage, request);
log(auditLog);
}
/**
* 构建审计日志对象
*/
private AuditLog buildAuditLog(String actionType, Long userId, String username,
Long tenantId, String resourceType, String resourceId,
String details, String result, String errorMessage,
HttpServletRequest request) {
return AuditLog.builder()
.actionType(actionType)
.userId(userId)
.username(username)
.tenantId(tenantId)
.resourceType(resourceType)
.resourceId(resourceId)
.details(details)
.result(result)
.errorMessage(errorMessage)
.ipAddress(getClientIp(request))
.userAgent(request != null ? request.getHeader("User-Agent") : null)
.build();
}
/**
* 获取客户端真实IP地址
*/
private String getClientIp(HttpServletRequest request) {
if (request == null) {
return null;
}
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 对于多级代理,取第一个IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
@Override
public PageResult<AuditLogResponse> queryAuditLogs(AuditLogQueryRequest request) {
// 构建查询条件
LambdaQueryWrapper<AuditLog> queryWrapper = new LambdaQueryWrapper<>();
// 用户ID
if (request.getUserId() != null) {
queryWrapper.eq(AuditLog::getUserId, request.getUserId());
}
// 用户名(模糊查询)
if (StringUtils.isNotBlank(request.getUsername())) {
queryWrapper.like(AuditLog::getUsername, request.getUsername());
}
// 操作类型
if (StringUtils.isNotBlank(request.getActionType())) {
queryWrapper.eq(AuditLog::getActionType, request.getActionType());
}
// 资源类型
if (StringUtils.isNotBlank(request.getResourceType())) {
queryWrapper.eq(AuditLog::getResourceType, request.getResourceType());
}
// 资源ID
if (StringUtils.isNotBlank(request.getResourceId())) {
queryWrapper.eq(AuditLog::getResourceId, request.getResourceId());
}
// 操作结果
if (StringUtils.isNotBlank(request.getResult())) {
queryWrapper.eq(AuditLog::getResult, request.getResult());
}
// 租户ID
if (request.getTenantId() != null) {
queryWrapper.eq(AuditLog::getTenantId, request.getTenantId());
}
// IP地址
if (StringUtils.isNotBlank(request.getIpAddress())) {
queryWrapper.eq(AuditLog::getIpAddress, request.getIpAddress());
}
// 时间范围查询
if (request.getStartDate() != null) {
queryWrapper.ge(AuditLog::getCreateTime, request.getStartDate());
}
if (request.getEndDate() != null) {
queryWrapper.le(AuditLog::getCreateTime, request.getEndDate());
}
// 关键词搜索(在详情中搜索)
if (StringUtils.isNotBlank(request.getKeyword())) {
queryWrapper.like(AuditLog::getDetails, request.getKeyword());
}
// 排序(默认按创建时间降序)
if (StringUtils.isNotBlank(request.getSortBy())) {
if ("desc".equalsIgnoreCase(request.getSortDirection())) {
queryWrapper.orderByDesc(getColumnByField(request.getSortBy()));
} else {
queryWrapper.orderByAsc(getColumnByField(request.getSortBy()));
}
} else {
queryWrapper.orderByDesc(AuditLog::getCreateTime);
}
// 查询总数
Long total = auditLogMapper.selectCount(queryWrapper);
// 分页查询
queryWrapper.last("LIMIT " + request.getLimit() + " OFFSET " + request.getOffset());
List<AuditLog> auditLogs = auditLogMapper.selectList(queryWrapper);
// 转换为DTO
List<AuditLogResponse> responses = auditLogs.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
return PageResult.of(responses, total, request.getPage(), request.getSize());
}
@Override
public AuditLogResponse getAuditLogById(Long id) {
AuditLog auditLog = auditLogMapper.selectById(id);
if (auditLog == null) {
throw new BusinessException("审计日志不存在");
}
return convertToResponse(auditLog);
}
/**
* 将 AuditLog 实体转换为 AuditLogResponse DTO
*/
private AuditLogResponse convertToResponse(AuditLog auditLog) {
AuditLogResponse response = new AuditLogResponse();
BeanUtils.copyProperties(auditLog, response);
return response;
}
/**
* 根据字段名获取排序列
*/
private com.baomidou.mybatisplus.core.toolkit.support.SFunction<AuditLog, ?> getColumnByField(String field) {
return switch (field) {
case "actionType" -> AuditLog::getActionType;
case "userId" -> AuditLog::getUserId;
case "username" -> AuditLog::getUsername;
case "result" -> AuditLog::getResult;
case "createTime" -> AuditLog::getCreateTime;
default -> AuditLog::getCreateTime;
};
}
}
@@ -0,0 +1,314 @@
package com.kurihada.auth.service.impl;
import com.kurihada.auth.constant.CacheConstants;
import com.kurihada.auth.dto.UserPermissionCache;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import com.kurihada.auth.service.PermissionCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 权限缓存服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PermissionCacheServiceImpl implements PermissionCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
@Override
public UserPermissionCache getUserPermissions(Long userId, Long tenantId) {
String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId);
// 尝试从缓存获取
UserPermissionCache cache = (UserPermissionCache) redisTemplate.opsForValue().get(cacheKey);
if (cache != null) {
log.debug("从缓存获取用户权限: userId={}, tenantId={}", userId, tenantId);
return cache;
}
log.debug("缓存未命中,从数据库加载用户权限: userId={}, tenantId={}", userId, tenantId);
// 缓存未命中,从数据库加载
return loadAndCacheUserPermissions(userId, tenantId);
}
@Override
public void cacheUserPermissions(Long userId, Long tenantId, User user) {
if (user == null) {
log.warn("用户对象为空,无法缓存权限: userId={}, tenantId={}", userId, tenantId);
return;
}
UserPermissionCache cache = buildUserPermissionCache(user, tenantId);
String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId);
redisTemplate.opsForValue().set(
cacheKey,
cache,
CacheConstants.PERMISSION_CACHE_TTL,
TimeUnit.SECONDS
);
// 将用户ID添加到租户用户集合中(用于批量删除)
String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId);
redisTemplate.opsForSet().add(tenantUsersKey, userId);
redisTemplate.expire(tenantUsersKey, CacheConstants.PERMISSION_CACHE_TTL, TimeUnit.SECONDS);
log.info("已缓存用户权限: userId={}, tenantId={}, roles={}, permissions={}",
userId, tenantId, cache.getRoleCodes().size(), cache.getPermissionCodes().size());
}
@Override
public void evictUserPermissions(Long userId, Long tenantId) {
String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId);
Boolean deleted = redisTemplate.delete(cacheKey);
if (Boolean.TRUE.equals(deleted)) {
log.info("已删除用户权限缓存: userId={}, tenantId={}", userId, tenantId);
}
// 从租户用户集合中移除
String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId);
redisTemplate.opsForSet().remove(tenantUsersKey, userId);
}
@Override
public void evictTenantPermissions(Long tenantId) {
String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId);
// 获取租户下所有用户ID
Set<Object> userIds = redisTemplate.opsForSet().members(tenantUsersKey);
if (CollectionUtils.isEmpty(userIds)) {
log.debug("租户下没有缓存的用户: tenantId={}", tenantId);
return;
}
// 批量删除用户权限缓存
int count = 0;
for (Object userId : userIds) {
if (userId instanceof Long) {
String cacheKey = CacheConstants.buildUserPermissionKey((Long) userId, tenantId);
Boolean deleted = redisTemplate.delete(cacheKey);
if (Boolean.TRUE.equals(deleted)) {
count++;
}
}
}
// 删除租户用户集合
redisTemplate.delete(tenantUsersKey);
log.info("已删除租户下所有用户权限缓存: tenantId={}, count={}", tenantId, count);
}
@Override
public void evictRoleRelatedPermissions(Long roleId, Long tenantId) {
// 获取拥有该角色的所有用户ID(直接使用 Mapper 避免循环依赖)
List<Long> userIds = userRoleMapper.selectUserIdsByRoleId(roleId);
if (CollectionUtils.isEmpty(userIds)) {
log.debug("没有用户拥有该角色: roleId={}, tenantId={}", roleId, tenantId);
return;
}
// 批量删除相关用户的权限缓存
int count = 0;
for (Long userId : userIds) {
evictUserPermissions(userId, tenantId);
count++;
}
log.info("已删除角色关联的用户权限缓存: roleId={}, tenantId={}, userCount={}",
roleId, tenantId, count);
}
@Override
public boolean hasPermission(Long userId, Long tenantId, String permissionCode) {
UserPermissionCache cache = getUserPermissions(userId, tenantId);
return cache != null && cache.hasPermission(permissionCode);
}
@Override
public boolean hasRole(Long userId, Long tenantId, String roleCode) {
UserPermissionCache cache = getUserPermissions(userId, tenantId);
return cache != null && cache.hasRole(roleCode);
}
@Override
public boolean hasResource(Long userId, Long tenantId, String resource) {
UserPermissionCache cache = getUserPermissions(userId, tenantId);
return cache != null && cache.hasResource(resource);
}
@Override
public void warmUpUserPermissions(Long userId, Long tenantId) {
log.info("预热用户权限缓存: userId={}, tenantId={}", userId, tenantId);
loadAndCacheUserPermissions(userId, tenantId);
}
@Override
public void clearAllPermissionCache() {
Set<String> keys = redisTemplate.keys(CacheConstants.USER_PERMISSIONS_KEY + "*");
if (!CollectionUtils.isEmpty(keys)) {
Long deletedCount = redisTemplate.delete(keys);
log.info("已清空所有权限缓存: count={}", deletedCount);
}
// 清空租户用户集合
Set<String> tenantKeys = redisTemplate.keys(CacheConstants.TENANT_USERS_KEY + "*");
if (!CollectionUtils.isEmpty(tenantKeys)) {
redisTemplate.delete(tenantKeys);
}
}
/**
* 从数据库加载并缓存用户权限
*/
private UserPermissionCache loadAndCacheUserPermissions(Long userId, Long tenantId) {
// 从数据库查询用户及其角色、权限
User user = userMapper.selectUserWithRolesAndPermissionsById(userId);
if (user == null) {
log.warn("用户不存在: userId={}", userId);
return null;
}
// 验证租户
if (!user.getTenantId().equals(tenantId)) {
log.warn("用户租户不匹配: userId={}, userTenantId={}, requestTenantId={}",
userId, user.getTenantId(), tenantId);
return null;
}
// 构建并缓存权限信息
UserPermissionCache cache = buildUserPermissionCache(user, tenantId);
String cacheKey = CacheConstants.buildUserPermissionKey(userId, tenantId);
redisTemplate.opsForValue().set(
cacheKey,
cache,
CacheConstants.PERMISSION_CACHE_TTL,
TimeUnit.SECONDS
);
// 将用户ID添加到租户用户集合
String tenantUsersKey = CacheConstants.buildTenantUsersKey(tenantId);
redisTemplate.opsForSet().add(tenantUsersKey, userId);
redisTemplate.expire(tenantUsersKey, CacheConstants.PERMISSION_CACHE_TTL, TimeUnit.SECONDS);
log.info("已从数据库加载并缓存用户权限: userId={}, tenantId={}", userId, tenantId);
return cache;
}
/**
* 构建用户权限缓存对象
*/
private UserPermissionCache buildUserPermissionCache(User user, Long tenantId) {
UserPermissionCache cache = new UserPermissionCache();
cache.setUserId(user.getId());
cache.setTenantId(tenantId);
cache.setUsername(user.getUsername());
cache.setCacheTime(System.currentTimeMillis());
// 初始化空集合,避免 null
List<String> roleCodes = new java.util.ArrayList<>();
List<String> roleNames = new java.util.ArrayList<>();
Set<String> permissionCodes = new HashSet<>();
Set<String> resources = new HashSet<>();
List<Role> roles = user.getRoles();
if (!CollectionUtils.isEmpty(roles)) {
// 提取角色代码和名称
roleCodes = roles.stream()
.map(Role::getCode)
.collect(Collectors.toList());
roleNames = roles.stream()
.map(Role::getName)
.collect(Collectors.toList());
// 提取所有权限
for (Role role : roles) {
List<Permission> permissions = role.getPermissions();
if (!CollectionUtils.isEmpty(permissions)) {
permissionCodes.addAll(permissions.stream()
.map(Permission::getCode)
.collect(Collectors.toSet()));
// 提取资源路径
resources.addAll(permissions.stream()
.map(Permission::getResource)
.filter(resource -> resource != null && !resource.isEmpty())
.collect(Collectors.toSet()));
}
}
}
// 设置集合(即使为空也设置,避免返回 null)
cache.setRoleCodes(roleCodes);
cache.setRoleNames(roleNames);
cache.setPermissionCodes(permissionCodes);
cache.setResources(resources);
return cache;
}
@Override
public org.springframework.security.core.userdetails.UserDetails buildUserDetailsFromCache(Long userId, Long tenantId, String username) {
// 从缓存获取用户权限
UserPermissionCache cache = getUserPermissions(userId, tenantId);
if (cache == null) {
log.debug("缓存未命中,无法构建 UserDetails: userId={}, tenantId={}", userId, tenantId);
return null;
}
// 构建权限列表(角色 + 权限)
Set<org.springframework.security.core.GrantedAuthority> authorities = new HashSet<>();
// 添加角色权限(ROLE_ 前缀)
if (cache.getRoleCodes() != null) {
cache.getRoleCodes().forEach(roleCode ->
authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + roleCode))
);
}
// 添加资源权限
if (cache.getPermissionCodes() != null) {
cache.getPermissionCodes().forEach(permissionCode ->
authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(permissionCode))
);
}
// 构建 UserDetails 对象(使用缓存中的数据,不需要密码)
return org.springframework.security.core.userdetails.User.builder()
.username(username)
.password("") // JWT 认证不需要密码验证
.authorities(authorities)
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build();
}
}
@@ -0,0 +1,488 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kurihada.auth.context.TenantContext;
import com.kurihada.auth.dto.CreatePermissionRequest;
import com.kurihada.auth.dto.PageRequest;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.PermissionDTO;
import com.kurihada.auth.dto.PermissionTreeNode;
import com.kurihada.auth.dto.UpdatePermissionRequest;
import com.kurihada.auth.entity.AuditLog;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.RolePermission;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.PermissionMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.service.AuditLogService;
import com.kurihada.auth.service.PermissionCacheService;
import com.kurihada.auth.service.PermissionService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.*;
import java.util.stream.Collectors;
/**
* 权限服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PermissionServiceImpl implements PermissionService {
private final PermissionMapper permissionMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserMapper userMapper;
private final PermissionCacheService permissionCacheService;
private final AuditLogService auditLogService;
// 系统内置权限,不允许删除
private static final Set<String> SYSTEM_PERMISSIONS = new HashSet<>(Arrays.asList(
"system:manage", "user:manage", "role:manage", "permission:manage"
));
@Override
public PageResult<PermissionDTO> getPermissionsPage(PageRequest pageRequest) {
Long tenantId = TenantContext.getTenantId();
// 构建查询条件
LambdaQueryWrapper<Permission> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.and(wrapper -> wrapper
.isNull(Permission::getTenantId)
.or()
.eq(tenantId != null, Permission::getTenantId, tenantId)
);
// 搜索条件
if (StringUtils.hasText(pageRequest.getKeyword())) {
queryWrapper.and(wrapper -> wrapper
.like(Permission::getName, pageRequest.getKeyword())
.or()
.like(Permission::getCode, pageRequest.getKeyword())
.or()
.like(Permission::getDescription, pageRequest.getKeyword())
.or()
.like(Permission::getResource, pageRequest.getKeyword())
);
}
// 排序
if (StringUtils.hasText(pageRequest.getSortBy())) {
boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection());
switch (pageRequest.getSortBy()) {
case "name" -> queryWrapper.orderBy(true, isAsc, Permission::getName);
case "code" -> queryWrapper.orderBy(true, isAsc, Permission::getCode);
case "type" -> queryWrapper.orderBy(true, isAsc, Permission::getType);
case "sort" -> queryWrapper.orderBy(true, isAsc, Permission::getSort);
case "status" -> queryWrapper.orderBy(true, isAsc, Permission::getStatus);
case "createTime" -> queryWrapper.orderBy(true, isAsc, Permission::getCreateTime);
default -> queryWrapper.orderByAsc(Permission::getSort).orderByDesc(Permission::getCreateTime);
}
} else {
queryWrapper.orderByAsc(Permission::getSort).orderByDesc(Permission::getCreateTime);
}
// 查询总数
Long total = permissionMapper.selectCount(queryWrapper);
// 分页查询 - 使用 MyBatis Plus 的安全分页方法
com.baomidou.mybatisplus.extension.plugins.pagination.Page<Permission> page =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
pageRequest.getPage(),
pageRequest.getSize()
);
page.setSearchCount(false); // 已经查询过总数,避免重复查询
com.baomidou.mybatisplus.extension.plugins.pagination.Page<Permission> result =
permissionMapper.selectPage(page, queryWrapper);
List<Permission> permissions = result.getRecords();
List<PermissionDTO> permissionDTOs = permissions.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
return PageResult.of(permissionDTOs, total, pageRequest.getPage(), pageRequest.getSize());
}
@Override
public PermissionDTO getPermissionById(Long id) {
Permission permission = permissionMapper.selectById(id);
if (permission == null) {
throw new BusinessException("权限不存在");
}
// 验证租户权限
Long tenantId = TenantContext.getTenantId();
if (permission.getTenantId() != null && tenantId != null && !tenantId.equals(permission.getTenantId())) {
throw new BusinessException("无权访问该权限");
}
return convertToDTO(permission);
}
@Override
@Transactional(rollbackFor = Exception.class)
public PermissionDTO createPermission(CreatePermissionRequest request) {
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证权限代码格式(小写字母、数字、冒号、下划线)
if (!request.getCode().matches("^[a-z0-9:_]+$")) {
auditLogPermission("CREATE_PERMISSION", null, "FAILURE", "权限代码格式不正确", request, currentUser, tenantId);
throw new BusinessException("权限代码只能包含小写字母、数字、冒号和下划线");
}
// 检查权限代码是否已存在
Long count = permissionMapper.selectCount(new LambdaQueryWrapper<Permission>()
.eq(Permission::getCode, request.getCode())
.and(wrapper -> wrapper
.isNull(Permission::getTenantId)
.or()
.eq(tenantId != null, Permission::getTenantId, tenantId)
));
if (count > 0) {
auditLogPermission("CREATE_PERMISSION", null, "FAILURE", "权限代码已存在", request, currentUser, tenantId);
throw new BusinessException("权限代码已存在");
}
// 创建权限
Permission permission = new Permission();
permission.setName(request.getName());
permission.setCode(request.getCode());
permission.setDescription(request.getDescription());
permission.setType(request.getType());
permission.setResource(request.getResource());
permission.setParentId(request.getParentId());
permission.setSort(request.getSort());
permission.setTenantId(tenantId);
permission.setStatus(1);
permissionMapper.insert(permission);
log.info("权限创建成功: {}, ID: {}", permission.getName(), permission.getId());
// 记录审计日志
auditLogPermission("CREATE_PERMISSION", permission.getId(), "SUCCESS", null, permission, currentUser, tenantId);
return convertToDTO(permission);
}
@Override
@Transactional(rollbackFor = Exception.class)
public PermissionDTO updatePermission(Long id, UpdatePermissionRequest request) {
Permission permission = permissionMapper.selectById(id);
if (permission == null) {
throw new BusinessException("权限不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限(公共权限不能修改)
if (permission.getTenantId() == null) {
auditLogPermission("UPDATE_PERMISSION", id, "FAILURE", "不能修改系统公共权限", request, currentUser, tenantId);
throw new BusinessException("不能修改系统公共权限");
}
if (tenantId != null && !tenantId.equals(permission.getTenantId())) {
auditLogPermission("UPDATE_PERMISSION", id, "FAILURE", "无权修改该权限", request, currentUser, tenantId);
throw new BusinessException("无权修改该权限");
}
String oldName = permission.getName();
String oldDescription = permission.getDescription();
// 更新权限信息
permission.setName(request.getName());
permission.setDescription(request.getDescription());
permission.setType(request.getType());
permission.setResource(request.getResource());
permission.setParentId(request.getParentId());
if (request.getSort() != null) {
permission.setSort(request.getSort());
}
permissionMapper.updateById(permission);
log.info("权限更新成功: {}, ID: {}", permission.getName(), permission.getId());
// 清理相关权限缓存
permissionCacheService.clearAllPermissionCache();
// 记录审计日志
String details = String.format("更新前: name=%s, description=%s; 更新后: name=%s, description=%s",
oldName, oldDescription, permission.getName(), permission.getDescription());
auditLogPermission("UPDATE_PERMISSION", id, "SUCCESS", details, permission, currentUser, tenantId);
return convertToDTO(permission);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deletePermission(Long id) {
Permission permission = permissionMapper.selectById(id);
if (permission == null) {
throw new BusinessException("权限不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限(公共权限不能删除)
if (permission.getTenantId() == null) {
auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "不能删除系统公共权限", permission, currentUser, tenantId);
throw new BusinessException("不能删除系统公共权限");
}
if (tenantId != null && !tenantId.equals(permission.getTenantId())) {
auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "无权删除该权限", permission, currentUser, tenantId);
throw new BusinessException("无权删除该权限");
}
// 防止删除系统内置权限
if (SYSTEM_PERMISSIONS.contains(permission.getCode())) {
auditLogPermission("DELETE_PERMISSION", id, "FAILURE", "不能删除系统内置权限", permission, currentUser, tenantId);
throw new BusinessException("不能删除系统内置权限: " + permission.getCode());
}
// 检查是否有角色正在使用该权限
Long roleCount = rolePermissionMapper.selectCount(new LambdaQueryWrapper<RolePermission>()
.eq(RolePermission::getPermissionId, id));
if (roleCount > 0) {
String errorMsg = String.format("该权限正被 %d 个角色使用,无法删除", roleCount);
auditLogPermission("DELETE_PERMISSION", id, "FAILURE", errorMsg, permission, currentUser, tenantId);
throw new BusinessException(errorMsg);
}
// 删除权限
permissionMapper.deleteById(id);
log.info("权限删除成功: {}, ID: {}", permission.getName(), permission.getId());
// 清理权限缓存
permissionCacheService.clearAllPermissionCache();
// 记录审计日志
auditLogPermission("DELETE_PERMISSION", id, "SUCCESS", null, permission, currentUser, tenantId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void togglePermissionStatus(Long id) {
Permission permission = permissionMapper.selectById(id);
if (permission == null) {
throw new BusinessException("权限不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限(公共权限不能修改状态)
if (permission.getTenantId() == null) {
auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "不能修改系统公共权限状态", permission, currentUser, tenantId);
throw new BusinessException("不能修改系统公共权限状态");
}
if (tenantId != null && !tenantId.equals(permission.getTenantId())) {
auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "无权修改该权限", permission, currentUser, tenantId);
throw new BusinessException("无权修改该权限");
}
// 防止禁用系统内置权限
if (SYSTEM_PERMISSIONS.contains(permission.getCode()) && permission.getStatus() == 1) {
auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "FAILURE", "不能禁用系统内置权限", permission, currentUser, tenantId);
throw new BusinessException("不能禁用系统内置权限: " + permission.getCode());
}
int oldStatus = permission.getStatus();
int newStatus = oldStatus == 1 ? 0 : 1;
permission.setStatus(newStatus);
permissionMapper.updateById(permission);
log.info("权限状态已更新: {}, 新状态: {}", permission.getName(), permission.getStatus());
// 清理权限缓存
permissionCacheService.clearAllPermissionCache();
// 记录审计日志
String details = String.format("状态从 %s 变更为 %s",
oldStatus == 1 ? "启用" : "禁用",
newStatus == 1 ? "启用" : "禁用");
auditLogPermission("TOGGLE_PERMISSION_STATUS", id, "SUCCESS", details, permission, currentUser, tenantId);
}
@Override
public List<PermissionTreeNode> getPermissionTree() {
Long tenantId = TenantContext.getTenantId();
// 查询所有权限(公共权限 + 当前租户权限)
List<Permission> allPermissions = permissionMapper.selectList(
new LambdaQueryWrapper<Permission>()
.and(wrapper -> wrapper
.isNull(Permission::getTenantId)
.or()
.eq(tenantId != null, Permission::getTenantId, tenantId)
)
.eq(Permission::getStatus, 1) // 只查询启用的权限
.orderByAsc(Permission::getSort)
.orderByDesc(Permission::getCreateTime)
);
// 将所有权限转换为树节点,并建立 ID -> 节点的映射
Map<Long, PermissionTreeNode> nodeMap = new HashMap<>();
for (Permission permission : allPermissions) {
PermissionTreeNode node = convertToTreeNode(permission);
nodeMap.put(permission.getId(), node);
}
// 构建树形结构
List<PermissionTreeNode> rootNodes = new ArrayList<>();
for (PermissionTreeNode node : nodeMap.values()) {
if (node.getParentId() == null || node.getParentId() == 0) {
// 根节点
rootNodes.add(node);
} else {
// 子节点,添加到父节点的 children 中
PermissionTreeNode parent = nodeMap.get(node.getParentId());
if (parent != null) {
parent.getChildren().add(node);
} else {
// 如果找不到父节点,作为根节点处理
rootNodes.add(node);
}
}
}
return rootNodes;
}
/**
* 转换为DTO
*/
private PermissionDTO convertToDTO(Permission permission) {
return PermissionDTO.builder()
.id(permission.getId())
.name(permission.getName())
.code(permission.getCode())
.tenantId(permission.getTenantId())
.description(permission.getDescription())
.type(permission.getType())
.resource(permission.getResource())
.parentId(permission.getParentId())
.sort(permission.getSort())
.status(permission.getStatus())
.createTime(permission.getCreateTime())
.updateTime(permission.getUpdateTime())
.build();
}
/**
* 转换为树节点
*/
private PermissionTreeNode convertToTreeNode(Permission permission) {
return PermissionTreeNode.builder()
.id(permission.getId())
.name(permission.getName())
.code(permission.getCode())
.tenantId(permission.getTenantId())
.type(permission.getType())
.resource(permission.getResource())
.parentId(permission.getParentId())
.sort(permission.getSort())
.status(permission.getStatus())
.createTime(permission.getCreateTime())
.updateTime(permission.getUpdateTime())
.children(new ArrayList<>())
.build();
}
/**
* 获取当前登录用户
*/
private User getCurrentUser() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
String username = authentication.getName();
if (!"anonymousUser".equals(username)) {
return userMapper.selectUserWithRolesAndPermissions(username);
}
}
} catch (Exception e) {
log.warn("获取当前用户信息失败: {}", e.getMessage());
}
return null;
}
/**
* 获取客户端IP地址
*/
private String getClientIp() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
} catch (Exception e) {
log.warn("获取客户端IP失败: {}", e.getMessage());
}
return null;
}
/**
* 获取User Agent
*/
private String getUserAgent() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
return attributes.getRequest().getHeader("User-Agent");
}
} catch (Exception e) {
log.warn("获取User-Agent失败: {}", e.getMessage());
}
return null;
}
/**
* 记录权限操作审计日志
*/
private void auditLogPermission(String actionType, Long permissionId, String result,
String errorMessage, Object details, User currentUser, Long tenantId) {
try {
AuditLog auditLog = AuditLog.builder()
.actionType(actionType)
.userId(currentUser != null ? currentUser.getId() : null)
.username(currentUser != null ? currentUser.getUsername() : "system")
.tenantId(tenantId != null ? tenantId : (currentUser != null ? currentUser.getTenantId() : null))
.resourceType("PERMISSION")
.resourceId(permissionId != null ? String.valueOf(permissionId) : null)
.details(details != null ? details.toString() : null)
.result(result)
.errorMessage(errorMessage)
.ipAddress(getClientIp())
.userAgent(getUserAgent())
.build();
auditLogService.log(auditLog);
} catch (Exception e) {
log.error("记录审计日志失败: {}", e.getMessage(), e);
}
}
}
@@ -0,0 +1,238 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kurihada.auth.entity.Permission;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.RolePermission;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.PermissionMapper;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.service.RolePermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 角色权限关联服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RolePermissionServiceImpl extends ServiceImpl<RolePermissionMapper, RolePermission> implements RolePermissionService {
private final RolePermissionMapper rolePermissionMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
private final com.kurihada.auth.service.PermissionCacheService permissionCacheService;
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assignPermissionToRole(Long roleId, Long permissionId) {
// 验证角色是否存在
Role role = roleMapper.selectById(roleId);
if (role == null) {
throw new BusinessException("角色不存在");
}
// 验证权限是否存在
Permission permission = permissionMapper.selectById(permissionId);
if (permission == null) {
throw new BusinessException("权限不存在");
}
// 验证租户一致性(权限的 tenantId 可能为 null,表示公共权限)
if (permission.getTenantId() != null && !permission.getTenantId().equals(role.getTenantId())) {
throw new BusinessException("权限和角色不属于同一租户,无法分配");
}
// 检查是否已经存在该关联
if (hasPermission(roleId, permissionId)) {
log.warn("角色ID: {} 已经拥有权限ID: {}", roleId, permissionId);
return true;
}
// 创建角色权限关联,包含租户ID
RolePermission rolePermission = new RolePermission();
rolePermission.setRoleId(roleId);
rolePermission.setPermissionId(permissionId);
rolePermission.setTenantId(role.getTenantId());
int result = rolePermissionMapper.insert(rolePermission);
if (result > 0) {
log.info("成功为角色ID: {} 分配权限ID: {},租户ID: {}", roleId, permissionId, role.getTenantId());
// 清除相关用户的权限缓存
permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId());
log.debug("已清除角色 {} 关联用户的权限缓存", roleId);
return true;
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assignPermissionsToRole(Long roleId, List<Long> permissionIds) {
// 验证角色是否存在
Role role = roleMapper.selectById(roleId);
if (role == null) {
throw new BusinessException("角色不存在");
}
if (permissionIds == null || permissionIds.isEmpty()) {
throw new BusinessException("权限ID列表不能为空");
}
// 验证所有权限是否存在,并检查租户一致性
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
if (permissions.size() != permissionIds.size()) {
throw new BusinessException("部分权限不存在");
}
// 验证所有权限都属于同一租户或为公共权限
boolean allValidTenant = permissions.stream()
.allMatch(permission -> permission.getTenantId() == null ||
permission.getTenantId().equals(role.getTenantId()));
if (!allValidTenant) {
throw new BusinessException("部分权限与角色不属于同一租户,无法分配");
}
// 过滤掉已存在的关联
List<Long> existingPermissionIds = getPermissionIdsByRoleId(roleId);
List<Long> newPermissionIds = permissionIds.stream()
.filter(pid -> !existingPermissionIds.contains(pid))
.collect(Collectors.toList());
if (newPermissionIds.isEmpty()) {
log.warn("所有权限已经分配给角色ID: {}", roleId);
return true;
}
// 批量创建角色权限关联
List<RolePermission> rolePermissions = newPermissionIds.stream()
.map(permissionId -> {
RolePermission rolePermission = new RolePermission();
rolePermission.setRoleId(roleId);
rolePermission.setPermissionId(permissionId);
rolePermission.setTenantId(role.getTenantId());
return rolePermission;
})
.collect(Collectors.toList());
boolean result = saveBatch(rolePermissions);
if (result) {
log.info("成功为角色ID: {} 批量分配 {} 个权限,租户ID: {}", roleId, rolePermissions.size(), role.getTenantId());
// 清除相关用户的权限缓存
permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId());
log.debug("已清除角色 {} 关联用户的权限缓存", roleId);
return true;
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removePermissionFromRole(Long roleId, Long permissionId) {
// 获取角色信息以获取租户ID
Role role = roleMapper.selectById(roleId);
int result = rolePermissionMapper.deleteByRoleIdAndPermissionId(roleId, permissionId);
if (result > 0) {
log.info("成功移除角色ID: {} 的权限ID: {}", roleId, permissionId);
// 清除相关用户的权限缓存
if (role != null) {
permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId());
log.debug("已清除角色 {} 关联用户的权限缓存", roleId);
}
return true;
}
log.warn("角色ID: {} 没有权限ID: {},无需移除", roleId, permissionId);
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeAllPermissionsFromRole(Long roleId) {
// 获取角色信息以获取租户ID
Role role = roleMapper.selectById(roleId);
int result = rolePermissionMapper.deleteByRoleId(roleId);
log.info("成功移除角色ID: {} 的所有权限,共 {} 条", roleId, result);
// 清除相关用户的权限缓存
if (role != null) {
permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId());
log.debug("已清除角色 {} 关联用户的权限缓存", roleId);
}
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeAllRolesFromPermission(Long permissionId) {
int result = rolePermissionMapper.deleteByPermissionId(permissionId);
log.info("成功移除权限ID: {} 的所有角色关联,共 {} 条", permissionId, result);
return true;
}
@Override
public List<Long> getPermissionIdsByRoleId(Long roleId) {
return rolePermissionMapper.selectPermissionIdsByRoleId(roleId);
}
@Override
public List<Permission> getPermissionsByRoleId(Long roleId) {
List<Long> permissionIds = getPermissionIdsByRoleId(roleId);
if (permissionIds.isEmpty()) {
return List.of();
}
return permissionMapper.selectBatchIds(permissionIds);
}
@Override
public List<Long> getRoleIdsByPermissionId(Long permissionId) {
return rolePermissionMapper.selectRoleIdsByPermissionId(permissionId);
}
@Override
public boolean hasPermission(Long roleId, Long permissionId) {
int count = rolePermissionMapper.existsByRoleIdAndPermissionId(roleId, permissionId);
return count > 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateRolePermissions(Long roleId, List<Long> permissionIds) {
// 验证角色是否存在
if (roleMapper.selectById(roleId) == null) {
throw new BusinessException("角色不存在");
}
// 先删除角色的所有权限
removeAllPermissionsFromRole(roleId);
// 如果新权限列表为空,则只删除不添加
if (permissionIds == null || permissionIds.isEmpty()) {
log.info("角色ID: {} 的权限已清空", roleId);
return true;
}
// 添加新的权限
boolean result = assignPermissionsToRole(roleId, permissionIds);
if (result) {
log.info("成功更新角色ID: {} 的权限,共 {} 个", roleId, permissionIds.size());
}
return result;
}
}
@@ -0,0 +1,398 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kurihada.auth.entity.AuditLog;
import com.kurihada.auth.context.TenantContext;
import com.kurihada.auth.dto.CreateRoleRequest;
import com.kurihada.auth.dto.PageRequest;
import com.kurihada.auth.dto.PageResult;
import com.kurihada.auth.dto.RoleDTO;
import com.kurihada.auth.dto.UpdateRoleRequest;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.RolePermission;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.RolePermissionMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import com.kurihada.auth.service.AuditLogService;
import com.kurihada.auth.service.PermissionCacheService;
import com.kurihada.auth.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 角色服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoleServiceImpl implements RoleService {
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserRoleMapper userRoleMapper;
private final UserMapper userMapper;
private final PermissionCacheService permissionCacheService;
private final AuditLogService auditLogService;
// 系统内置角色,不允许删除
private static final Set<String> SYSTEM_ROLES = new HashSet<>(Arrays.asList("ADMIN", "SUPER_ADMIN"));
@Override
public PageResult<RoleDTO> getRolesPage(PageRequest pageRequest) {
Long tenantId = TenantContext.getTenantId();
// 构建查询条件
LambdaQueryWrapper<Role> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(tenantId != null, Role::getTenantId, tenantId);
// 搜索条件
if (StringUtils.hasText(pageRequest.getKeyword())) {
queryWrapper.and(wrapper -> wrapper
.like(Role::getName, pageRequest.getKeyword())
.or()
.like(Role::getCode, pageRequest.getKeyword())
.or()
.like(Role::getDescription, pageRequest.getKeyword())
);
}
// 排序
if (StringUtils.hasText(pageRequest.getSortBy())) {
boolean isAsc = "asc".equalsIgnoreCase(pageRequest.getSortDirection());
switch (pageRequest.getSortBy()) {
case "name" -> queryWrapper.orderBy(true, isAsc, Role::getName);
case "code" -> queryWrapper.orderBy(true, isAsc, Role::getCode);
case "status" -> queryWrapper.orderBy(true, isAsc, Role::getStatus);
case "createTime" -> queryWrapper.orderBy(true, isAsc, Role::getCreateTime);
default -> queryWrapper.orderByDesc(Role::getCreateTime);
}
} else {
queryWrapper.orderByDesc(Role::getCreateTime);
}
// 查询总数
Long total = roleMapper.selectCount(queryWrapper);
// 分页查询 - 使用 MyBatis Plus 的安全分页方法
com.baomidou.mybatisplus.extension.plugins.pagination.Page<Role> page =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
pageRequest.getPage(),
pageRequest.getSize()
);
page.setSearchCount(false); // 已经查询过总数,避免重复查询
com.baomidou.mybatisplus.extension.plugins.pagination.Page<Role> result =
roleMapper.selectPage(page, queryWrapper);
List<Role> roles = result.getRecords();
List<RoleDTO> roleDTOs = roles.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
return PageResult.of(roleDTOs, total, pageRequest.getPage(), pageRequest.getSize());
}
@Override
public RoleDTO getRoleById(Long id) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
// 验证租户权限
Long tenantId = TenantContext.getTenantId();
if (tenantId != null && !tenantId.equals(role.getTenantId())) {
throw new BusinessException("无权访问该角色");
}
return convertToDTO(role);
}
@Override
@Transactional(rollbackFor = Exception.class)
public RoleDTO createRole(CreateRoleRequest request) {
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证角色代码格式(大写字母和下划线)
if (!request.getCode().matches("^[A-Z_]+$")) {
auditLogRole("CREATE_ROLE", null, "FAILURE", "角色代码格式不正确", request, currentUser, tenantId);
throw new BusinessException("角色代码只能包含大写字母和下划线");
}
// 检查角色代码是否已存在
Long count = roleMapper.selectCount(new LambdaQueryWrapper<Role>()
.eq(Role::getCode, request.getCode())
.eq(tenantId != null, Role::getTenantId, tenantId));
if (count > 0) {
auditLogRole("CREATE_ROLE", null, "FAILURE", "角色代码已存在", request, currentUser, tenantId);
throw new BusinessException("角色代码已存在");
}
// 创建角色
Role role = new Role();
role.setName(request.getName());
role.setCode(request.getCode());
role.setDescription(request.getDescription());
role.setTenantId(tenantId);
role.setStatus(1);
roleMapper.insert(role);
log.info("角色创建成功: {}, ID: {}", role.getName(), role.getId());
// 记录审计日志
auditLogRole("CREATE_ROLE", role.getId(), "SUCCESS", null, role, currentUser, tenantId);
return convertToDTO(role);
}
@Override
@Transactional(rollbackFor = Exception.class)
public RoleDTO updateRole(Long id, UpdateRoleRequest request) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限
if (tenantId != null && !tenantId.equals(role.getTenantId())) {
auditLogRole("UPDATE_ROLE", id, "FAILURE", "无权修改该角色", request, currentUser, tenantId);
throw new BusinessException("无权修改该角色");
}
String oldName = role.getName();
String oldDescription = role.getDescription();
// 更新角色信息
role.setName(request.getName());
role.setDescription(request.getDescription());
roleMapper.updateById(role);
log.info("角色更新成功: {}, ID: {}", role.getName(), role.getId());
// 清理相关用户的权限缓存
permissionCacheService.evictRoleRelatedPermissions(id, tenantId);
// 记录审计日志
String details = String.format("更新前: name=%s, description=%s; 更新后: name=%s, description=%s",
oldName, oldDescription, role.getName(), role.getDescription());
auditLogRole("UPDATE_ROLE", id, "SUCCESS", details, role, currentUser, tenantId);
return convertToDTO(role);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteRole(Long id) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限
if (tenantId != null && !tenantId.equals(role.getTenantId())) {
auditLogRole("DELETE_ROLE", id, "FAILURE", "无权删除该角色", role, currentUser, tenantId);
throw new BusinessException("无权删除该角色");
}
// 防止删除系统内置角色
if (SYSTEM_ROLES.contains(role.getCode())) {
auditLogRole("DELETE_ROLE", id, "FAILURE", "不能删除系统内置角色", role, currentUser, tenantId);
throw new BusinessException("不能删除系统内置角色: " + role.getCode());
}
// 检查是否有用户正在使用该角色
int userCount = userRoleMapper.countUsersByRoleId(id);
if (userCount > 0) {
String errorMsg = String.format("该角色正被 %d 个用户使用,无法删除", userCount);
auditLogRole("DELETE_ROLE", id, "FAILURE", errorMsg, role, currentUser, tenantId);
throw new BusinessException(errorMsg);
}
// 删除角色和权限的关联
rolePermissionMapper.delete(new LambdaQueryWrapper<RolePermission>()
.eq(RolePermission::getRoleId, id));
// 删除角色
roleMapper.deleteById(id);
log.info("角色删除成功: {}, ID: {}", role.getName(), role.getId());
// 清理相关用户的权限缓存
permissionCacheService.evictRoleRelatedPermissions(id, tenantId);
// 记录审计日志
auditLogRole("DELETE_ROLE", id, "SUCCESS", null, role, currentUser, tenantId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void toggleRoleStatus(Long id) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
Long tenantId = TenantContext.getTenantId();
User currentUser = getCurrentUser();
// 验证租户权限
if (tenantId != null && !tenantId.equals(role.getTenantId())) {
auditLogRole("TOGGLE_ROLE_STATUS", id, "FAILURE", "无权修改该角色", role, currentUser, tenantId);
throw new BusinessException("无权修改该角色");
}
// 防止禁用系统内置角色
if (SYSTEM_ROLES.contains(role.getCode()) && role.getStatus() == 1) {
auditLogRole("TOGGLE_ROLE_STATUS", id, "FAILURE", "不能禁用系统内置角色", role, currentUser, tenantId);
throw new BusinessException("不能禁用系统内置角色: " + role.getCode());
}
int oldStatus = role.getStatus();
int newStatus = oldStatus == 1 ? 0 : 1;
role.setStatus(newStatus);
roleMapper.updateById(role);
log.info("角色状态已更新: {}, 新状态: {}", role.getName(), role.getStatus());
// 清理相关用户的权限缓存
permissionCacheService.evictRoleRelatedPermissions(id, tenantId);
// 记录审计日志
String details = String.format("状态从 %s 变更为 %s",
oldStatus == 1 ? "启用" : "禁用",
newStatus == 1 ? "启用" : "禁用");
auditLogRole("TOGGLE_ROLE_STATUS", id, "SUCCESS", details, role, currentUser, tenantId);
}
/**
* 转换为DTO
*/
private RoleDTO convertToDTO(Role role) {
// 获取角色的权限
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(
new LambdaQueryWrapper<RolePermission>()
.eq(RolePermission::getRoleId, role.getId())
);
Set<String> permissionCodes = rolePermissions.stream()
.map(rp -> String.valueOf(rp.getPermissionId()))
.collect(Collectors.toSet());
return RoleDTO.builder()
.id(role.getId())
.name(role.getName())
.code(role.getCode())
.tenantId(role.getTenantId())
.description(role.getDescription())
.status(role.getStatus())
.createTime(role.getCreateTime())
.updateTime(role.getUpdateTime())
.permissions(permissionCodes)
.build();
}
/**
* 获取当前登录用户
*/
private User getCurrentUser() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
String username = authentication.getName();
if (!"anonymousUser".equals(username)) {
return userMapper.selectUserWithRolesAndPermissions(username);
}
}
} catch (Exception e) {
log.warn("获取当前用户信息失败: {}", e.getMessage());
}
return null;
}
/**
* 获取客户端IP地址
*/
private String getClientIp() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
} catch (Exception e) {
log.warn("获取客户端IP失败: {}", e.getMessage());
}
return null;
}
/**
* 获取User Agent
*/
private String getUserAgent() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
return attributes.getRequest().getHeader("User-Agent");
}
} catch (Exception e) {
log.warn("获取User-Agent失败: {}", e.getMessage());
}
return null;
}
/**
* 记录角色操作审计日志
*/
private void auditLogRole(String actionType, Long roleId, String result,
String errorMessage, Object details, User currentUser, Long tenantId) {
try {
AuditLog auditLog = AuditLog.builder()
.actionType(actionType)
.userId(currentUser != null ? currentUser.getId() : null)
.username(currentUser != null ? currentUser.getUsername() : "system")
.tenantId(tenantId != null ? tenantId : (currentUser != null ? currentUser.getTenantId() : null))
.resourceType("ROLE")
.resourceId(roleId != null ? String.valueOf(roleId) : null)
.details(details != null ? details.toString() : null)
.result(result)
.errorMessage(errorMessage)
.ipAddress(getClientIp())
.userAgent(getUserAgent())
.build();
auditLogService.log(auditLog);
} catch (Exception e) {
log.error("记录审计日志失败: {}", e.getMessage(), e);
}
}
}
@@ -0,0 +1,318 @@
package com.kurihada.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kurihada.auth.entity.Role;
import com.kurihada.auth.entity.User;
import com.kurihada.auth.entity.UserRole;
import com.kurihada.auth.exception.BusinessException;
import com.kurihada.auth.mapper.RoleMapper;
import com.kurihada.auth.mapper.UserMapper;
import com.kurihada.auth.mapper.UserRoleMapper;
import com.kurihada.auth.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 用户角色关联服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserRoleServiceImpl extends ServiceImpl<UserRoleMapper, UserRole> implements UserRoleService {
private final UserRoleMapper userRoleMapper;
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final com.kurihada.auth.service.PermissionCacheService permissionCacheService;
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assignRoleToUser(Long userId, Long roleId) {
// 验证用户是否存在
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 验证角色是否存在
Role role = roleMapper.selectById(roleId);
if (role == null) {
throw new BusinessException("角色不存在");
}
// 验证租户一致性
if (!user.getTenantId().equals(role.getTenantId())) {
throw new BusinessException("用户和角色不属于同一租户,无法分配");
}
// 检查是否已经存在该关联
if (hasRole(userId, roleId)) {
log.warn("用户ID: {} 已经拥有角色ID: {}", userId, roleId);
return true;
}
// 创建用户角色关联,包含租户ID
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(roleId);
userRole.setTenantId(user.getTenantId());
int result = userRoleMapper.insert(userRole);
if (result > 0) {
log.info("成功为用户ID: {} 分配角色ID: {},租户ID: {}", userId, roleId, user.getTenantId());
// 清除用户权限缓存
permissionCacheService.evictUserPermissions(userId, user.getTenantId());
log.debug("已清除用户 {} 的权限缓存", userId);
return true;
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assignRolesToUser(Long userId, List<Long> roleIds) {
// 验证用户是否存在
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
if (roleIds == null || roleIds.isEmpty()) {
throw new BusinessException("角色ID列表不能为空");
}
// 验证所有角色是否存在,并检查租户一致性
List<Role> roles = roleMapper.selectBatchIds(roleIds);
if (roles.size() != roleIds.size()) {
throw new BusinessException("部分角色不存在");
}
// 验证所有角色都属于同一租户
boolean allSameTenant = roles.stream()
.allMatch(role -> role.getTenantId().equals(user.getTenantId()));
if (!allSameTenant) {
throw new BusinessException("部分角色与用户不属于同一租户,无法分配");
}
// 过滤掉已存在的关联
List<Long> existingRoleIds = getRoleIdsByUserId(userId);
List<Long> newRoleIds = roleIds.stream()
.filter(rid -> !existingRoleIds.contains(rid))
.collect(Collectors.toList());
if (newRoleIds.isEmpty()) {
log.warn("所有角色已经分配给用户ID: {}", userId);
return true;
}
// 批量创建用户角色关联
List<UserRole> userRoles = newRoleIds.stream()
.map(roleId -> {
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(roleId);
userRole.setTenantId(user.getTenantId());
return userRole;
})
.collect(Collectors.toList());
boolean result = saveBatch(userRoles);
if (result) {
log.info("成功为用户ID: {} 批量分配 {} 个角色,租户ID: {}", userId, userRoles.size(), user.getTenantId());
// 清除用户权限缓存
permissionCacheService.evictUserPermissions(userId, user.getTenantId());
log.debug("已清除用户 {} 的权限缓存", userId);
return true;
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assignUsersToRole(Long roleId, List<Long> userIds) {
// 验证角色是否存在
if (roleMapper.selectById(roleId) == null) {
throw new BusinessException("角色不存在");
}
if (userIds == null || userIds.isEmpty()) {
throw new BusinessException("用户ID列表不能为空");
}
// 验证所有用户是否存在
long existingCount = userMapper.selectCount(
new LambdaQueryWrapper<User>()
.in(User::getId, userIds)
);
if (existingCount != userIds.size()) {
throw new BusinessException("部分用户不存在");
}
// 过滤掉已存在的关联
List<Long> existingUserIds = getUserIdsByRoleId(roleId);
List<Long> newUserIds = userIds.stream()
.filter(uid -> !existingUserIds.contains(uid))
.collect(Collectors.toList());
if (newUserIds.isEmpty()) {
log.warn("所有用户已经拥有角色ID: {}", roleId);
return true;
}
int result = userRoleMapper.batchInsertUsers(roleId, newUserIds);
if (result > 0) {
log.info("成功为角色ID: {} 批量分配 {} 个用户", roleId, result);
return true;
}
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeRoleFromUser(Long userId, Long roleId) {
// 获取用户信息以获取租户ID
User user = userMapper.selectById(userId);
int result = userRoleMapper.deleteByUserIdAndRoleId(userId, roleId);
if (result > 0) {
log.info("成功移除用户ID: {} 的角色ID: {}", userId, roleId);
// 清除用户权限缓存
if (user != null) {
permissionCacheService.evictUserPermissions(userId, user.getTenantId());
log.debug("已清除用户 {} 的权限缓存", userId);
}
return true;
}
log.warn("用户ID: {} 没有角色ID: {},无需移除", userId, roleId);
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeAllRolesFromUser(Long userId) {
// 获取用户信息以获取租户ID
User user = userMapper.selectById(userId);
int result = userRoleMapper.deleteByUserId(userId);
log.info("成功移除用户ID: {} 的所有角色,共 {} 条", userId, result);
// 清除用户权限缓存
if (user != null) {
permissionCacheService.evictUserPermissions(userId, user.getTenantId());
log.debug("已清除用户 {} 的权限缓存", userId);
}
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeAllUsersFromRole(Long roleId) {
// 获取角色信息以获取租户ID
Role role = roleMapper.selectById(roleId);
int result = userRoleMapper.deleteByRoleId(roleId);
log.info("成功移除角色ID: {} 的所有用户关联,共 {} 条", roleId, result);
// 清除该角色关联的所有用户的权限缓存
if (role != null) {
permissionCacheService.evictRoleRelatedPermissions(roleId, role.getTenantId());
log.debug("已清除角色 {} 关联用户的权限缓存", roleId);
}
return true;
}
@Override
public List<Long> getRoleIdsByUserId(Long userId) {
return userRoleMapper.selectRoleIdsByUserId(userId);
}
@Override
public List<Role> getRolesByUserId(Long userId) {
List<Long> roleIds = getRoleIdsByUserId(userId);
if (roleIds.isEmpty()) {
return List.of();
}
return roleMapper.selectBatchIds(roleIds);
}
@Override
public List<Long> getUserIdsByRoleId(Long roleId) {
return userRoleMapper.selectUserIdsByRoleId(roleId);
}
@Override
public List<User> getUsersByRoleId(Long roleId) {
List<Long> userIds = getUserIdsByRoleId(roleId);
if (userIds.isEmpty()) {
return List.of();
}
return userMapper.selectBatchIds(userIds);
}
@Override
public boolean hasRole(Long userId, Long roleId) {
int count = userRoleMapper.existsByUserIdAndRoleId(userId, roleId);
return count > 0;
}
@Override
public boolean hasRoleByCode(Long userId, String roleCode) {
// 获取用户的所有角色
List<Role> roles = getRolesByUserId(userId);
// 检查是否有匹配的角色代码
return roles.stream()
.anyMatch(role -> role.getCode().equals(roleCode));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateUserRoles(Long userId, List<Long> roleIds) {
// 验证用户是否存在
if (userMapper.selectById(userId) == null) {
throw new BusinessException("用户不存在");
}
// 先删除用户的所有角色
removeAllRolesFromUser(userId);
// 如果新角色列表为空,则只删除不添加
if (roleIds == null || roleIds.isEmpty()) {
log.info("用户ID: {} 的角色已清空", userId);
return true;
}
// 添加新的角色
boolean result = assignRolesToUser(userId, roleIds);
if (result) {
log.info("成功更新用户ID: {} 的角色,共 {} 个", userId, roleIds.size());
}
return result;
}
@Override
public int countUsersByRoleId(Long roleId) {
return userRoleMapper.countUsersByRoleId(roleId);
}
@Override
public int countRolesByUserId(Long userId) {
return userRoleMapper.countRolesByUserId(userId);
}
}
@@ -0,0 +1,160 @@
package com.kurihada.auth.util;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;
/**
* IP 工具类
*/
public class IpUtil {
private static final String UNKNOWN = "unknown";
private static final String LOCALHOST_IPV4 = "127.0.0.1";
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
/**
* 获取客户端真实 IP 地址
* 支持通过代理、负载均衡等场景
*/
public static String getIpAddress(HttpServletRequest request) {
if (request == null) {
return UNKNOWN;
}
String ip = null;
// 1. X-Forwarded-For: 通过代理时的原始IP
ip = request.getHeader("X-Forwarded-For");
if (isValidIp(ip)) {
// 多次代理时,会有多个IP,取第一个非unknown的有效IP
int index = ip.indexOf(',');
if (index != -1) {
ip = ip.substring(0, index);
}
return ip.trim();
}
// 2. X-Real-IP: Nginx 代理设置的真实IP
ip = request.getHeader("X-Real-IP");
if (isValidIp(ip)) {
return ip.trim();
}
// 3. Proxy-Client-IP: Apache 代理设置
ip = request.getHeader("Proxy-Client-IP");
if (isValidIp(ip)) {
return ip.trim();
}
// 4. WL-Proxy-Client-IP: WebLogic 代理设置
ip = request.getHeader("WL-Proxy-Client-IP");
if (isValidIp(ip)) {
return ip.trim();
}
// 5. HTTP_CLIENT_IP
ip = request.getHeader("HTTP_CLIENT_IP");
if (isValidIp(ip)) {
return ip.trim();
}
// 6. HTTP_X_FORWARDED_FOR
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
if (isValidIp(ip)) {
return ip.trim();
}
// 7. 最后使用 request.getRemoteAddr()
ip = request.getRemoteAddr();
// 将 IPv6 的 localhost 转换为 IPv4
if (LOCALHOST_IPV6.equals(ip)) {
ip = LOCALHOST_IPV4;
}
return StringUtils.hasText(ip) ? ip.trim() : UNKNOWN;
}
/**
* 验证 IP 是否有效
*/
private static boolean isValidIp(String ip) {
return StringUtils.hasText(ip) &&
!UNKNOWN.equalsIgnoreCase(ip);
}
/**
* 判断是否为内网IP
*/
public static boolean isInternalIp(String ip) {
if (!StringUtils.hasText(ip)) {
return false;
}
if (LOCALHOST_IPV4.equals(ip) || LOCALHOST_IPV6.equals(ip)) {
return true;
}
// 私有地址范围:
// 10.0.0.0 - 10.255.255.255
// 172.16.0.0 - 172.31.255.255
// 192.168.0.0 - 192.168.255.255
byte[] addr = textToNumericFormatV4(ip);
if (addr == null) {
return false;
}
final byte b0 = addr[0];
final byte b1 = addr[1];
// 10.x.x.x
final byte SECTION_1 = 0x0A;
// 172.16.x.x - 172.31.x.x
final byte SECTION_2 = (byte) 0xAC;
final byte SECTION_3 = (byte) 0x10;
final byte SECTION_4 = (byte) 0x1F;
// 192.168.x.x
final byte SECTION_5 = (byte) 0xC0;
final byte SECTION_6 = (byte) 0xA8;
switch (b0) {
case SECTION_1:
return true;
case SECTION_2:
return b1 >= SECTION_3 && b1 <= SECTION_4;
case SECTION_5:
return b1 == SECTION_6;
default:
return false;
}
}
/**
* 将 IPv4 地址字符串转换为字节数组
*/
private static byte[] textToNumericFormatV4(String text) {
if (text == null || text.isEmpty()) {
return null;
}
String[] parts = text.split("\\.");
if (parts.length != 4) {
return null;
}
byte[] bytes = new byte[4];
try {
for (int i = 0; i < 4; i++) {
int value = Integer.parseInt(parts[i]);
if (value < 0 || value > 255) {
return null;
}
bytes[i] = (byte) value;
}
} catch (NumberFormatException e) {
return null;
}
return bytes;
}
}
@@ -0,0 +1,155 @@
package com.kurihada.auth.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* JWT工具类
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
/**
* 获取签名密钥
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 从token中获取用户名
*/
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
/**
* 从token中获取租户ID
*/
public Long getTenantIdFromToken(String token) {
Claims claims = getAllClaimsFromToken(token);
Object tenantId = claims.get("tenantId");
if (tenantId instanceof Integer) {
return ((Integer) tenantId).longValue();
} else if (tenantId instanceof Long) {
return (Long) tenantId;
}
return null;
}
/**
* 从token中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = getAllClaimsFromToken(token);
Object userId = claims.get("userId");
if (userId instanceof Integer) {
return ((Integer) userId).longValue();
} else if (userId instanceof Long) {
return (Long) userId;
}
return null;
}
/**
* 从token中获取过期时间
*/
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
/**
* 从token中获取Claims
*/
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
/**
* 从token中获取所有Claims
*/
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 检查token是否过期
*/
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
/**
* 生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername());
}
/**
* 生成token(带额外信息)
*/
public String generateToken(String username, Map<String, Object> claims) {
return doGenerateToken(claims, username);
}
/**
* 执行生成token
*/
private String doGenerateToken(Map<String, Object> claims, String subject) {
final Date createdDate = new Date();
final Date expirationDate = new Date(createdDate.getTime() + expiration);
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(createdDate)
.expiration(expirationDate)
.signWith(getSigningKey())
.compact();
}
/**
* 验证token
*/
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/**
* 验证token是否有效
*/
public Boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,179 @@
package com.kurihada.auth.util;
import java.util.regex.Pattern;
/**
* 密码强度评估工具
*/
public class PasswordStrengthUtil {
/**
* 密码强度等级
*/
public enum PasswordStrength {
VERY_WEAK(0, "非常弱"),
WEAK(1, ""),
MEDIUM(2, "中等"),
STRONG(3, ""),
VERY_STRONG(4, "非常强");
private final int level;
private final String description;
PasswordStrength(int level, String description) {
this.level = level;
this.description = description;
}
public int getLevel() {
return level;
}
public String getDescription() {
return description;
}
}
/**
* 评估密码强度
*
* @param password 密码
* @return 密码强度等级
*/
public static PasswordStrength assessPasswordStrength(String password) {
if (password == null || password.isEmpty()) {
return PasswordStrength.VERY_WEAK;
}
int score = 0;
// 1. 长度评分
if (password.length() >= 8) score += 1;
if (password.length() >= 12) score += 1;
if (password.length() >= 16) score += 1;
// 2. 字符类型评分
if (Pattern.compile("[a-z]").matcher(password).find()) score += 1; // 小写字母
if (Pattern.compile("[A-Z]").matcher(password).find()) score += 1; // 大写字母
if (Pattern.compile("[0-9]").matcher(password).find()) score += 1; // 数字
if (Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]").matcher(password).find()) score += 1; // 特殊字符
// 3. 字符多样性评分
long uniqueChars = password.chars().distinct().count();
if (uniqueChars >= password.length() * 0.6) score += 1; // 60%以上字符不重复
// 4. 扣分项
if (hasSequentialChars(password, 3)) score -= 1; // 连续字符
if (hasRepeatingChars(password, 3)) score -= 1; // 重复字符
// 根据分数返回强度等级
if (score >= 8) return PasswordStrength.VERY_STRONG;
if (score >= 6) return PasswordStrength.STRONG;
if (score >= 4) return PasswordStrength.MEDIUM;
if (score >= 2) return PasswordStrength.WEAK;
return PasswordStrength.VERY_WEAK;
}
/**
* 检查是否包含连续字符
*/
private static boolean hasSequentialChars(String password, int length) {
if (password.length() < length) {
return false;
}
for (int i = 0; i <= password.length() - length; i++) {
boolean isSequential = true;
char firstChar = password.charAt(i);
for (int j = 1; j < length; j++) {
if (password.charAt(i + j) != firstChar + j) {
isSequential = false;
break;
}
}
if (isSequential) {
return true;
}
}
return false;
}
/**
* 检查是否包含重复字符
*/
private static boolean hasRepeatingChars(String password, int length) {
if (password.length() < length) {
return false;
}
for (int i = 0; i <= password.length() - length; i++) {
char firstChar = password.charAt(i);
boolean isRepeating = true;
for (int j = 1; j < length; j++) {
if (password.charAt(i + j) != firstChar) {
isRepeating = false;
break;
}
}
if (isRepeating) {
return true;
}
}
return false;
}
/**
* 生成密码强度建议
*/
public static String getPasswordSuggestion(String password) {
if (password == null || password.isEmpty()) {
return "请输入密码";
}
StringBuilder suggestions = new StringBuilder();
if (password.length() < 8) {
suggestions.append("• 密码长度至少8位\n");
}
if (!Pattern.compile("[a-z]").matcher(password).find()) {
suggestions.append("• 添加小写字母\n");
}
if (!Pattern.compile("[A-Z]").matcher(password).find()) {
suggestions.append("• 添加大写字母\n");
}
if (!Pattern.compile("[0-9]").matcher(password).find()) {
suggestions.append("• 添加数字\n");
}
if (!Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]").matcher(password).find()) {
suggestions.append("• 添加特殊字符\n");
}
if (hasSequentialChars(password, 3)) {
suggestions.append("• 避免使用连续字符(如123、abc)\n");
}
if (hasRepeatingChars(password, 3)) {
suggestions.append("• 避免使用重复字符(如aaa、111)\n");
}
if (suggestions.length() == 0) {
return "密码强度良好";
}
return "建议改进:\n" + suggestions.toString();
}
/**
* 计算密码强度百分比(0-100)
*/
public static int getPasswordStrengthPercentage(String password) {
PasswordStrength strength = assessPasswordStrength(password);
return strength.getLevel() * 25; // 0, 25, 50, 75, 100
}
}

Some files were not shown because too many files have changed in this diff Show More