feat: 实现完整的 RBAC 权限管理系统与基础设施增强
在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理), 集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、 RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例
This commit is contained in:
@@ -2,6 +2,9 @@ package com.aisi.template;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@SpringBootApplication
|
||||
public class TemplateApplication {
|
||||
|
||||
62
src/main/java/com/aisi/template/annotation/AuditLog.java
Normal file
62
src/main/java/com/aisi/template/annotation/AuditLog.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package com.aisi.template.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 审计日志注解
|
||||
* 标注需要记录审计日志的方法
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登录/登出
|
||||
* - 数据创建/更新/删除
|
||||
* - 敏感操作访问
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* @AuditLog(
|
||||
* action = "LOGIN",
|
||||
* resource = "user",
|
||||
* description = "用户 {0} 登录成功"
|
||||
* )
|
||||
* public void login(String username) { ... }
|
||||
* </pre>
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface AuditLog {
|
||||
|
||||
/**
|
||||
* 操作类型
|
||||
* 常见值:
|
||||
* - LOGIN:登录
|
||||
* - LOGOUT:登出
|
||||
* - CREATE:创建
|
||||
* - UPDATE:更新
|
||||
* - DELETE:删除
|
||||
*/
|
||||
String action();
|
||||
|
||||
/**
|
||||
* 资源类型
|
||||
* 常见值:
|
||||
* - user:用户
|
||||
* - role:角色
|
||||
* - permission:权限
|
||||
* - menu:菜单
|
||||
*/
|
||||
String resource();
|
||||
|
||||
/**
|
||||
* 操作描述模板
|
||||
* 说明:
|
||||
* - 支持使用 {0}, {1} 等占位符引用方法参数
|
||||
* - 示例:"更新用户 {0}",参数:["123"] → "更新用户 123"
|
||||
*/
|
||||
String description() default "";
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.aisi.template.annotation;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
/**
|
||||
* 密码验证器
|
||||
* 为 @StrongPassword 注解提供验证逻辑
|
||||
*
|
||||
* 验证规则:
|
||||
* 1. 最小长度检查
|
||||
* 2. 大写字母检查
|
||||
* 3. 小写字母检查
|
||||
* 4. 数字检查
|
||||
* 5. 特殊字符检查
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class PasswordValidator implements ConstraintValidator<StrongPassword, String> {
|
||||
|
||||
/**
|
||||
* 最小密码长度
|
||||
*/
|
||||
private int minLength;
|
||||
|
||||
/**
|
||||
* 是否需要大写字母
|
||||
*/
|
||||
private boolean requireUppercase;
|
||||
|
||||
/**
|
||||
* 是否需要小写字母
|
||||
*/
|
||||
private boolean requireLowercase;
|
||||
|
||||
/**
|
||||
* 是否需要数字
|
||||
*/
|
||||
private boolean requireDigit;
|
||||
|
||||
/**
|
||||
* 是否需要特殊字符
|
||||
*/
|
||||
private boolean requireSpecialChar;
|
||||
|
||||
/**
|
||||
* 初始化验证器
|
||||
* 步骤:
|
||||
* 1. 从注解中读取配置参数
|
||||
* 2. 保存到实例变量
|
||||
*
|
||||
* @param constraintAnnotation 强密码注解
|
||||
*/
|
||||
@Override
|
||||
public void initialize(StrongPassword constraintAnnotation) {
|
||||
this.minLength = constraintAnnotation.minLength();
|
||||
this.requireUppercase = constraintAnnotation.requireUppercase();
|
||||
this.requireLowercase = constraintAnnotation.requireLowercase();
|
||||
this.requireDigit = constraintAnnotation.requireDigit();
|
||||
this.requireSpecialChar = constraintAnnotation.requireSpecialChar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码是否符合要求
|
||||
* 步骤:
|
||||
* 1. 检查是否为空
|
||||
* 2. 检查最小长度
|
||||
* 3. 检查大写字母
|
||||
* 4. 检查小写字母
|
||||
* 5. 检查数字
|
||||
* 6. 检查特殊字符
|
||||
*
|
||||
* @param password 密码
|
||||
* @param context 约束验证上下文
|
||||
* @return 是否有效
|
||||
*/
|
||||
@Override
|
||||
public boolean isValid(String password, ConstraintValidatorContext context) {
|
||||
// 1. 检查是否为空
|
||||
if (password == null || password.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查最小长度
|
||||
if (password.length() < minLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查大写字母
|
||||
if (requireUppercase && !containsUppercase(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 检查小写字母
|
||||
if (requireLowercase && !containsLowercase(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 检查数字
|
||||
if (requireDigit && !containsDigit(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. 检查特殊字符
|
||||
if (requireSpecialChar && !containsSpecialChar(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含大写字母
|
||||
* 说明:
|
||||
* - 将字符串转为小写后与原字符串比较
|
||||
* - 如果不同,说明包含大写字母
|
||||
*
|
||||
* @param password 密码
|
||||
* @return 是否包含大写字母
|
||||
*/
|
||||
private boolean containsUppercase(String password) {
|
||||
return !password.equals(password.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含小写字母
|
||||
* 说明:
|
||||
* - 将字符串转为大写后与原字符串比较
|
||||
* - 如果不同,说明包含小写字母
|
||||
*
|
||||
* @param password 密码
|
||||
* @return 是否包含小写字母
|
||||
*/
|
||||
private boolean containsLowercase(String password) {
|
||||
return !password.equals(password.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含数字
|
||||
* 说明:
|
||||
* - 使用正则表达式匹配
|
||||
* - \\d 表示数字
|
||||
*
|
||||
* @param password 密码
|
||||
* @return 是否包含数字
|
||||
*/
|
||||
private boolean containsDigit(String password) {
|
||||
return password.matches(".*\\d.*");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含特殊字符
|
||||
* 说明:
|
||||
* - 特殊字符包括:!@#$%^&*()_+-=[]{}|;:',."\\|,.<>/?
|
||||
* - 使用正则表达式匹配
|
||||
*
|
||||
* @param password 密码
|
||||
* @return 是否包含特殊字符
|
||||
*/
|
||||
private boolean containsSpecialChar(String password) {
|
||||
return password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*");
|
||||
}
|
||||
}
|
||||
88
src/main/java/com/aisi/template/annotation/RateLimit.java
Normal file
88
src/main/java/com/aisi/template/annotation/RateLimit.java
Normal file
@@ -0,0 +1,88 @@
|
||||
package com.aisi.template.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 限流注解
|
||||
* 标注需要进行限流的方法
|
||||
*
|
||||
* 使用场景:
|
||||
* - 登录接口:防止暴力破解
|
||||
* - API 接口:防止恶意刷接口
|
||||
* - 抢购活动:防止刷单
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* @RateLimit(
|
||||
* permits = 5,
|
||||
* seconds = 60,
|
||||
* limitType = LimitType.IP,
|
||||
* keyPrefix = "login:"
|
||||
* )
|
||||
* public void login(String username) { ... }
|
||||
* </pre>
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RateLimit {
|
||||
|
||||
/**
|
||||
* 时间窗口内允许的最大请求数
|
||||
* - 默认:5 次
|
||||
*/
|
||||
int permits() default 5;
|
||||
|
||||
/**
|
||||
* 时间窗口(秒)
|
||||
* - 默认:60 秒
|
||||
*/
|
||||
int seconds() default 60;
|
||||
|
||||
/**
|
||||
* 限流类型
|
||||
* - IP:按 IP 地址限流
|
||||
* - USER:按用户ID限流(需要登录)
|
||||
* - GLOBAL:全局限流(所有用户共享计数器)
|
||||
*/
|
||||
LimitType limitType() default LimitType.IP;
|
||||
|
||||
/**
|
||||
* Redis 键前缀
|
||||
* - 默认:"rate_limit:"
|
||||
* - 可自定义,如:"login:", "api:" 等
|
||||
*/
|
||||
String keyPrefix() default "rate_limit:";
|
||||
|
||||
/**
|
||||
* 限流类型枚举
|
||||
*/
|
||||
enum LimitType {
|
||||
/**
|
||||
* 按 IP 地址限流
|
||||
* - 每个 IP 独立计数
|
||||
* - 适用于防止恶意刷接口
|
||||
*/
|
||||
IP,
|
||||
|
||||
/**
|
||||
* 按用户 ID 限流
|
||||
* - 每个用户独立计数
|
||||
* - 需要用户登录
|
||||
* - 适用于用户操作限流
|
||||
*/
|
||||
USER,
|
||||
|
||||
/**
|
||||
* 全局限流
|
||||
* - 所有用户共享计数器
|
||||
* - 适用于系统整体限流
|
||||
*/
|
||||
GLOBAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.aisi.template.annotation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 强密码验证注解
|
||||
* 验证密码是否符合安全要求
|
||||
*
|
||||
* 默认要求:
|
||||
* - 至少 8 个字符
|
||||
* - 包含至少一个大写字母
|
||||
* - 包含至少一个小写字母
|
||||
* - 包含至少一个数字
|
||||
* - 包含至少一个特殊字符
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* @StrongPassword(
|
||||
* minLength = 10,
|
||||
* requireUppercase = true,
|
||||
* requireLowercase = true,
|
||||
* requireDigit = true,
|
||||
* requireSpecialChar = true
|
||||
* )
|
||||
* private String password;
|
||||
* </pre>
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = PasswordValidator.class)
|
||||
public @interface StrongPassword {
|
||||
|
||||
/**
|
||||
* 错误消息
|
||||
* - 默认:"密码必须至少8个字符,包含大小写字母、数字和特殊字符"
|
||||
*/
|
||||
String message() default "密码必须至少8个字符,包含大小写字母、数字和特殊字符";
|
||||
|
||||
/**
|
||||
* 分组
|
||||
*/
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
/**
|
||||
* 负载
|
||||
*/
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
/**
|
||||
* 最小密码长度
|
||||
* - 默认:8
|
||||
*/
|
||||
int minLength() default 8;
|
||||
|
||||
/**
|
||||
* 是否需要大写字母
|
||||
* - 默认:true
|
||||
*/
|
||||
boolean requireUppercase() default true;
|
||||
|
||||
/**
|
||||
* 是否需要小写字母
|
||||
* - 默认:true
|
||||
*/
|
||||
boolean requireLowercase() default true;
|
||||
|
||||
/**
|
||||
* 是否需要数字
|
||||
* - 默认:true
|
||||
*/
|
||||
boolean requireDigit() default true;
|
||||
|
||||
/**
|
||||
* 是否需要特殊字符
|
||||
* - 默认:true
|
||||
* - 特殊字符包括:!@#$%^&*()_+-=[]{}|;:,.<>?
|
||||
*/
|
||||
boolean requireSpecialChar() default true;
|
||||
}
|
||||
217
src/main/java/com/aisi/template/aspect/AuditLogAspect.java
Normal file
217
src/main/java/com/aisi/template/aspect/AuditLogAspect.java
Normal file
@@ -0,0 +1,217 @@
|
||||
package com.aisi.template.aspect;
|
||||
|
||||
import com.aisi.template.annotation.AuditLog;
|
||||
import com.aisi.template.service.AuditLogService;
|
||||
import com.aisi.template.utils.SecurityUtils;
|
||||
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.util.Objects;
|
||||
|
||||
/**
|
||||
* 审计日志切面
|
||||
* 自动记录带有 @AuditLog 注解的方法调用
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 自动记录操作日志
|
||||
* 2. 记录操作用户、时间、IP等信息
|
||||
* 3. 记录操作结果(成功/失败)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登录/登出
|
||||
* - 数据创建/更新/删除
|
||||
* - 敏感操作访问
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogAspect {
|
||||
|
||||
/**
|
||||
* 审计日志服务
|
||||
*/
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* 环绕通知:记录审计日志
|
||||
* 步骤:
|
||||
* 1. 获取请求信息(方法、URI、IP)
|
||||
* 2. 获取当前用户信息
|
||||
* 3. 执行目标方法
|
||||
* 4. 捕获执行结果或异常
|
||||
* 5. 记录审计日志
|
||||
*
|
||||
* @param joinPoint 连接点(被拦截的方法)
|
||||
* @param auditLog 审计日志注解
|
||||
* @return 方法执行结果
|
||||
* @throws Throwable 方法执行异常
|
||||
*/
|
||||
@Around("@annotation(auditLog)")
|
||||
public Object logAudit(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
|
||||
// 1. 获取请求信息
|
||||
HttpServletRequest request = getRequest();
|
||||
String action = auditLog.action();
|
||||
String resource = auditLog.resource();
|
||||
|
||||
// 2. 获取当前用户信息
|
||||
Long userId = null;
|
||||
String username = null;
|
||||
try {
|
||||
userId = SecurityUtils.getUserId();
|
||||
username = SecurityUtils.getUsername();
|
||||
} catch (Exception e) {
|
||||
// 用户未登录,跳过用户信息获取
|
||||
}
|
||||
|
||||
// 3. 提取请求信息
|
||||
String requestMethod = request != null ? request.getMethod() : null;
|
||||
String requestUri = request != null ? request.getRequestURI() : null;
|
||||
String ipAddress = getClientIp(request);
|
||||
String userAgent = request != null ? request.getHeader("User-Agent") : null;
|
||||
|
||||
// 4. 执行目标方法并记录结果
|
||||
Object result = null;
|
||||
String errorMessage = null;
|
||||
int status = 1; // 1=成功,0=失败
|
||||
|
||||
try {
|
||||
// 4.1 执行目标方法
|
||||
result = joinPoint.proceed();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
// 4.2 捕获异常
|
||||
status = 0; // 标记为失败
|
||||
errorMessage = e.getMessage();
|
||||
throw e;
|
||||
} finally {
|
||||
// 5. 记录审计日志
|
||||
try {
|
||||
// 5.1 构建操作描述
|
||||
String description = buildDescription(auditLog.description(), joinPoint.getArgs());
|
||||
|
||||
// 5.2 提取资源ID(从方法参数中)
|
||||
String resourceId = extractResourceId(joinPoint.getArgs());
|
||||
|
||||
// 5.3 保存审计日志
|
||||
auditLogService.log(userId, username, action, resource, resourceId,
|
||||
description, requestMethod, requestUri, ipAddress,
|
||||
userAgent, status, errorMessage);
|
||||
} catch (Exception e) {
|
||||
log.error("保存审计日志失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 HTTP 请求
|
||||
*
|
||||
* @return HttpServletRequest 对象
|
||||
*/
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attributes != null ? attributes.getRequest() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP 地址
|
||||
* 步骤:
|
||||
* 1. 先从 X-Forwarded-For 头获取(代理服务器设置)
|
||||
* 2. 再从 X-Real-IP 头获取(Nginx 等设置)
|
||||
* 3. 最后从 remoteAddr 获取
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @return 客户端 IP 地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 从 X-Forwarded-For 获取(可能有多个 IP,取第一个)
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
// 2. 从 X-Real-IP 获取
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
// 3. 从 remoteAddr 获取
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 4. 处理多个 IP 的情况(X-Forwarded-For 格式:客户端IP, 代理1IP, 代理2IP)
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建操作描述
|
||||
* 说明:
|
||||
* - 支持使用 {0}, {1} 等占位符引用方法参数
|
||||
* - 示例:description = "更新用户 {0}",args = ["123"]
|
||||
* 结果 = "更新用户 123"
|
||||
*
|
||||
* @param template 描述模板
|
||||
* @param args 方法参数
|
||||
* @return 填充后的描述
|
||||
*/
|
||||
private String buildDescription(String template, Object[] args) {
|
||||
if (template == null || template.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String description = template;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String placeholder = "{" + i + "}";
|
||||
if (description.contains(placeholder)) {
|
||||
String argValue = args[i] != null ? args[i].toString() : "null";
|
||||
description = description.replace(placeholder, argValue);
|
||||
}
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从方法参数中提取资源ID
|
||||
* 说明:
|
||||
* - 查找第一个 Long、Integer 或数字字符串类型的参数
|
||||
* - 作为资源ID记录
|
||||
*
|
||||
* @param args 方法参数
|
||||
* @return 资源ID
|
||||
*/
|
||||
private String extractResourceId(Object[] args) {
|
||||
// 遍历方法参数,查找 ID
|
||||
for (Object arg : args) {
|
||||
if (arg instanceof Long) {
|
||||
return arg.toString();
|
||||
}
|
||||
if (arg instanceof Integer) {
|
||||
return arg.toString();
|
||||
}
|
||||
if (arg instanceof String) {
|
||||
String str = (String) arg;
|
||||
// 检查是否为数字字符串(可能为 ID)
|
||||
if (str.matches("\\d+")) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
174
src/main/java/com/aisi/template/aspect/RateLimitAspect.java
Normal file
174
src/main/java/com/aisi/template/aspect/RateLimitAspect.java
Normal file
@@ -0,0 +1,174 @@
|
||||
package com.aisi.template.aspect;
|
||||
|
||||
import com.aisi.template.annotation.RateLimit;
|
||||
import com.aisi.template.exception.RateLimitExceededException;
|
||||
import com.aisi.template.utils.RedisUtils;
|
||||
import com.aisi.template.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 限流切面
|
||||
* 基于 Redis 实现的分布式限流功能
|
||||
*
|
||||
* 实现原理:
|
||||
* 1. 使用 Redis 计数器记录请求次数
|
||||
* 2. 在指定时间窗口内,请求次数超过限制则拒绝
|
||||
* 3. 支持按 IP、用户ID 或全局限流
|
||||
*
|
||||
* 使用场景:
|
||||
* - 登录接口:防止暴力破解
|
||||
* - API 接口:防止恶意刷接口
|
||||
* - 抢购活动:防止刷单
|
||||
*
|
||||
* 限流算法:固定窗口计数器
|
||||
* 优点:实现简单,性能好
|
||||
* 缺点:窗口边界可能出现流量突增
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitAspect {
|
||||
|
||||
/**
|
||||
* Redis 工具类
|
||||
*/
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
/**
|
||||
* 限流检查方法
|
||||
* 步骤:
|
||||
* 1. 根据限流类型构建 Redis 键
|
||||
* 2. 获取当前计数
|
||||
* 3. 检查是否超过限制
|
||||
* 4. 如果未超过,增加计数
|
||||
*
|
||||
* @param joinPoint 连接点(被拦截的方法)
|
||||
* @param rateLimit 限流注解
|
||||
* @throws RateLimitExceededException 当超过限流阈值时抛出异常
|
||||
*/
|
||||
@Before("@annotation(rateLimit)")
|
||||
public void rateLimit(JoinPoint joinPoint, RateLimit rateLimit) {
|
||||
// 1. 构建 Redis 键
|
||||
String key = buildKey(rateLimit);
|
||||
|
||||
// 2. 原子递增计数
|
||||
Long currentCount = redisUtils.increment(key);
|
||||
if (currentCount == null) {
|
||||
currentCount = 1L;
|
||||
}
|
||||
|
||||
// 3. 首次请求设置固定窗口过期时间
|
||||
if (currentCount == 1L) {
|
||||
redisUtils.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// 4. 检查是否超过限制
|
||||
if (currentCount > rateLimit.permits()) {
|
||||
log.warn("触发限流 - key: {}, count: {}, limit: {}",
|
||||
key, currentCount, rateLimit.permits());
|
||||
throw new RateLimitExceededException();
|
||||
}
|
||||
|
||||
log.debug("限流检查通过 - key: {}, count: {}/{}",
|
||||
key, currentCount, rateLimit.permits());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建限流 Redis 键
|
||||
* 步骤:
|
||||
* 1. 获取前缀(rate_limit:)
|
||||
* 2. 根据限流类型添加标识
|
||||
* - IP: rate_limit:ip:127.0.0.1
|
||||
* - USER: rate_limit:user:123
|
||||
* - GLOBAL: rate_limit:global
|
||||
*
|
||||
* @param rateLimit 限流注解
|
||||
* @return Redis 键
|
||||
*/
|
||||
private String buildKey(RateLimit rateLimit) {
|
||||
StringBuilder keyBuilder = new StringBuilder(rateLimit.keyPrefix());
|
||||
|
||||
// 1. 根据限流类型构建键
|
||||
switch (rateLimit.limitType()) {
|
||||
case IP:
|
||||
// 按 IP 限流
|
||||
String ip = getClientIp();
|
||||
keyBuilder.append("ip:").append(ip);
|
||||
break;
|
||||
|
||||
case USER:
|
||||
// 按用户 ID 限流
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
if (userId != null) {
|
||||
keyBuilder.append("user:").append(userId);
|
||||
} else {
|
||||
// 用户未登录,降级为 IP 限流
|
||||
keyBuilder.append("ip:").append(getClientIp());
|
||||
}
|
||||
break;
|
||||
|
||||
case GLOBAL:
|
||||
// 全局限流(所有用户共享计数器)
|
||||
keyBuilder.append("global");
|
||||
break;
|
||||
}
|
||||
|
||||
return keyBuilder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP 地址
|
||||
* 步骤:
|
||||
* 1. 先从 X-Forwarded-For 头获取(可能有多个 IP,取第一个)
|
||||
* 2. 再从 X-Real-IP 头获取
|
||||
* 3. 最后从 remoteAddr 获取
|
||||
*
|
||||
* 说明:
|
||||
* - X-Forwarded-For: 记录请求经过的所有代理 IP
|
||||
* - 格式:客户端IP, 代理1IP, 代理2IP, ...
|
||||
* - 取第一个 IP 即为真实客户端 IP
|
||||
*
|
||||
* @return 客户端 IP 地址
|
||||
*/
|
||||
private String getClientIp() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
|
||||
// 1. 从 X-Forwarded-For 获取
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
|
||||
// 2. 从 X-Real-IP 获取
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 3. 处理多个 IP 的情况(X-Forwarded-For 可能包含多个 IP)
|
||||
if (ip != null && ip.contains(",")) {
|
||||
// 取第一个 IP(真实客户端 IP)
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip != null ? ip : "unknown";
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,47 @@ import org.springframework.context.annotation.Configuration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Jackson 配置类
|
||||
* 配置 JSON 序列化相关设置
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 日期时间格式化:统一日期时间格式
|
||||
* 2. 禁用时间戳:使用可读的日期字符串
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
/**
|
||||
* 配置 ObjectMapper
|
||||
* 步骤:
|
||||
* 1. 创建 ObjectMapper 对象
|
||||
* 2. 注册 JavaTimeModule(支持 Java 8 日期时间类型)
|
||||
* 3. 配置 LocalDateTime 序列化格式
|
||||
* 4. 禁用时间戳格式
|
||||
*
|
||||
* @return ObjectMapper 对象
|
||||
*/
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
// 配置日期时间格式
|
||||
// 1. 配置日期时间模块
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
|
||||
// 定义日期时间格式
|
||||
// 2. 定义日期时间格式:yyyy-MM-dd HH:mm:ss
|
||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
|
||||
|
||||
// 3. 注册日期时间模块
|
||||
mapper.registerModule(javaTimeModule);
|
||||
|
||||
// 禁用时间戳格式
|
||||
// 4. 禁用时间戳格式(使用可读的日期字符串)
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,19 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
/**
|
||||
* JPA 配置类
|
||||
* 配置 JPA 审计功能
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 自动填充创建时间:@CreatedDate
|
||||
* 2. 自动填充更新时间:@LastModifiedDate
|
||||
*
|
||||
* 使用说明:
|
||||
* - 在实体类上添加 @EntityListeners(AuditingEntityListener.class)
|
||||
* - 在字段上使用 @CreatedDate 和 @LastModifiedDate 注解
|
||||
* - JPA 会自动维护这些字段的值
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing
|
||||
|
||||
@@ -2,14 +2,49 @@ package com.aisi.template.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI (Swagger) 配置类
|
||||
* 配置 API 文档的安全认证
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 配置 JWT Bearer 认证
|
||||
* 2. 在 Swagger UI 中支持 JWT Token 测试
|
||||
*
|
||||
* 使用说明:
|
||||
* 1. 访问 http://localhost:8080/swagger-ui.html
|
||||
* 2. 点击右上角 "Authorize" 按钮
|
||||
* 3. 输入 JWT Token(格式:Bearer {token})
|
||||
* 4. 点击 "Authorize" 完成认证
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
@SecurityScheme(
|
||||
// 安全方案名称
|
||||
name = "Bearer Authentication",
|
||||
// 安全方案类型:HTTP
|
||||
type = SecuritySchemeType.HTTP,
|
||||
// Bearer 格式:JWT
|
||||
bearerFormat = "JWT",
|
||||
// 认证方案:bearer
|
||||
scheme = "bearer"
|
||||
)
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI customOpenAPI(@Value("${app.openapi.server-url:http://localhost:8080}") String serverUrl) {
|
||||
return new OpenAPI()
|
||||
.servers(List.of(new Server()
|
||||
.url(serverUrl)
|
||||
.description("Default server")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,45 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 配置类
|
||||
* 配置 Redis 序列化方式
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. RedisTemplate:用于操作对象类型数据
|
||||
* 2. StringRedisTemplate:用于操作字符串类型数据
|
||||
*
|
||||
* 序列化配置:
|
||||
* - Key:使用 String 序列化
|
||||
* - Value:使用 JSON 序列化(支持类型信息)
|
||||
* - HashKey:使用 String 序列化
|
||||
* - HashValue:使用 JSON 序列化
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/**
|
||||
* 配置 RedisTemplate(用于操作对象)
|
||||
* 步骤:
|
||||
* 1. 创建 RedisTemplate 对象
|
||||
* 2. 配置 ObjectMapper(支持类型信息)
|
||||
* 3. 配置 Key 序列化方式
|
||||
* 4. 配置 Value 序列化方式
|
||||
*
|
||||
* @param factory Redis 连接工厂
|
||||
* @param objectMapper Jackson ObjectMapper
|
||||
* @return RedisTemplate 对象
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) {
|
||||
// 1. 创建 RedisTemplate 对象
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
|
||||
// 2. 配置 ObjectMapper(支持类型信息,用于反序列化时确定类型)
|
||||
ObjectMapper redisMapper = objectMapper.copy();
|
||||
redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
redisMapper.activateDefaultTyping(
|
||||
@@ -29,18 +60,35 @@ public class RedisConfig {
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
|
||||
// 3. 配置序列化器
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper);
|
||||
|
||||
// 3.1 Key 使用 String 序列化
|
||||
template.setKeySerializer(stringSerializer);
|
||||
// 3.2 HashKey 使用 String 序列化
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
// 3.3 Value 使用 JSON 序列化
|
||||
template.setValueSerializer(jsonSerializer);
|
||||
// 3.4 HashValue 使用 JSON 序列化
|
||||
template.setHashValueSerializer(jsonSerializer);
|
||||
|
||||
// 4. 执行初始化
|
||||
template.afterPropertiesSet();
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 StringRedisTemplate(用于操作字符串)
|
||||
* 说明:
|
||||
* - StringRedisTemplate 专门用于处理字符串类型数据
|
||||
* - Key 和 Value 都使用 String 序列化
|
||||
* - 适用于简单的键值对操作
|
||||
*
|
||||
* @param factory Redis 连接工厂
|
||||
* @return StringRedisTemplate 对象
|
||||
*/
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
|
||||
return new StringRedisTemplate(factory);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aisi.template.config;
|
||||
|
||||
import com.aisi.template.filter.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
@@ -19,46 +20,128 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security 配置类
|
||||
* 配置安全认证和授权相关设置
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. JWT 认证:配置 JWT 过滤器
|
||||
* 2. CORS 配置:跨域资源共享配置
|
||||
* 3. 会话管理:无状态会话(JWT)
|
||||
* 4. 授权规则:配置哪些请求需要认证
|
||||
* 5. 密码编码:配置 BCrypt 密码编码器
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
|
||||
/**
|
||||
* JWT 认证过滤器
|
||||
* 用于拦截请求并验证 JWT Token
|
||||
*/
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* 允许的跨域来源
|
||||
* 从配置文件读取,默认:http://localhost:5173, http://localhost:3000
|
||||
*/
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
|
||||
private List<String> allowedOrigins;
|
||||
|
||||
/**
|
||||
* 配置安全过滤器链
|
||||
* 步骤:
|
||||
* 1. 禁用 CSRF(使用 JWT 不需要 CSRF 保护)
|
||||
* 2. 配置 CORS
|
||||
* 3. 设置会话管理为无状态(JWT)
|
||||
* 4. 配置授权规则
|
||||
* 5. 添加 JWT 认证过滤器
|
||||
*
|
||||
* @param http HttpSecurity 对象
|
||||
* @return 安全过滤器链
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// 1. 禁用 CSRF(JWT 不需要)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
// 2. 配置 CORS
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
// 3. 设置会话管理为无状态
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// 4. 配置授权规则
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll()
|
||||
// 4.1 Swagger 文档:允许匿名访问
|
||||
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
// 4.2 用户注册、登录和刷新令牌:允许匿名访问
|
||||
.requestMatchers("/api/v1/user/register", "/api/v1/user/login", "/api/v1/user/refresh").permitAll()
|
||||
// 4.3 密码重置:允许匿名访问
|
||||
.requestMatchers("/api/v1/user/password-reset/**").permitAll()
|
||||
// 4.4 获取当前用户信息:需要认证
|
||||
.requestMatchers("/api/v1/user/info").authenticated()
|
||||
// 4.5 其他所有请求:需要认证
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
// 5. 添加 JWT 认证过滤器(在用户名密码过滤器之前)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 CORS(跨域资源共享)
|
||||
* 步骤:
|
||||
* 1. 创建 CORS 配置
|
||||
* 2. 设置允许的来源
|
||||
* 3. 设置允许的 HTTP 方法
|
||||
* 4. 设置允许的请求头
|
||||
* 5. 设置暴露的响应头
|
||||
* 6. 允许携带凭证(Cookie)
|
||||
* 7. 设置预检请求缓存时间
|
||||
*
|
||||
* @return CORS 配置源
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
// 1. 创建 CORS 配置
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
configuration.setAllowedMethods(List.of("*"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
|
||||
// 2. 设置允许的来源
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
|
||||
// 3. 设置允许的 HTTP 方法
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
|
||||
// 4. 设置允许的请求头
|
||||
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept", "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"));
|
||||
|
||||
// 5. 设置暴露的响应头
|
||||
configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
|
||||
|
||||
// 6. 允许携带凭证(Cookie)
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setExposedHeaders(List.of("*"));
|
||||
|
||||
// 7. 设置预检请求缓存时间(1 小时)
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
// 8. 注册 CORS 配置
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置密码编码器
|
||||
* 说明:
|
||||
* - 使用 BCrypt 算法对密码进行加密
|
||||
* - 每次加密都会生成不同的哈希值(自带盐值)
|
||||
* - 强度因子默认为 10
|
||||
*
|
||||
* @return BCrypt 密码编码器
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
||||
@@ -6,17 +6,41 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 配置类
|
||||
* 配置拦截器和其他 Web 相关设置
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 拦截器配置:注册日志拦截器
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
/**
|
||||
* 日志拦截器
|
||||
* 用于记录请求和响应信息
|
||||
*/
|
||||
private final LoggingInterceptor loggingInterceptor;
|
||||
|
||||
|
||||
/**
|
||||
* 配置拦截器
|
||||
* 步骤:
|
||||
* 1. 注册日志拦截器
|
||||
* 2. 拦截所有请求(/**)
|
||||
* 3. 可配置排除路径
|
||||
*
|
||||
* @param registry 拦截器注册表
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(loggingInterceptor)
|
||||
// 1. 拦截所有请求
|
||||
.addPathPatterns("/**")
|
||||
// 2. 排除路径(可在此添加需要排除的路径)
|
||||
.excludePathPatterns();
|
||||
}
|
||||
}
|
||||
|
||||
134
src/main/java/com/aisi/template/constants/AppConstants.java
Normal file
134
src/main/java/com/aisi/template/constants/AppConstants.java
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.aisi.template.constants;
|
||||
|
||||
/**
|
||||
* 应用级常量类
|
||||
* 定义系统中的通用常量
|
||||
*
|
||||
* 主要分类:
|
||||
* 1. 分页配置:默认页大小、最大页大小
|
||||
* 2. 日期时间:格式、时区
|
||||
* 3. 用户状态:启用、禁用
|
||||
* 4. 菜单类型:目录、菜单、按钮
|
||||
* 5. 密码重置:过期时间、冷却时间
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class AppConstants {
|
||||
|
||||
/**
|
||||
* 默认分页大小
|
||||
* - 每页返回的记录数
|
||||
*/
|
||||
public static final int DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 最大分页大小
|
||||
* - 防止一次查询过多数据
|
||||
*/
|
||||
public static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* 日期时间格式(JSON 序列化)
|
||||
* - 格式:yyyy-MM-dd HH:mm:ss
|
||||
* - 示例:2024-04-09 14:30:00
|
||||
*/
|
||||
public static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
/**
|
||||
* 中国时区
|
||||
* - 东八区(UTC+8)
|
||||
*/
|
||||
public static final String TIME_ZONE_CHINA = "Asia/Shanghai";
|
||||
|
||||
/**
|
||||
* 默认语言环境
|
||||
* - 简体中文
|
||||
*/
|
||||
public static final String DEFAULT_LOCALE = "zh_CN";
|
||||
|
||||
/**
|
||||
* 审计日志保留天数
|
||||
* - 超过此天数的日志将被删除
|
||||
*/
|
||||
public static final int AUDIT_LOG_RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* 密码重置验证码过期时间(分钟)
|
||||
* - 验证码的有效期
|
||||
*/
|
||||
public static final int PASSWORD_RESET_CODE_EXPIRE_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* 密码重置请求冷却时间(秒)
|
||||
* - 同一邮箱两次请求的最小间隔
|
||||
*/
|
||||
public static final int PASSWORD_RESET_REQUEST_COOLDOWN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* 密码重置最大尝试次数
|
||||
* - 验证码验证失败超过此次数后失效
|
||||
*/
|
||||
public static final int PASSWORD_RESET_MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* 用户状态常量
|
||||
*/
|
||||
public static final class UserStatus {
|
||||
/**
|
||||
* 用户禁用
|
||||
*/
|
||||
public static final int DISABLED = 0;
|
||||
|
||||
/**
|
||||
* 用户启用
|
||||
*/
|
||||
public static final int ENABLED = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单类型常量
|
||||
*/
|
||||
public static final class MenuType {
|
||||
/**
|
||||
* 目录
|
||||
* - 用于分组,不对应具体页面
|
||||
*/
|
||||
public static final int DIRECTORY = 1;
|
||||
|
||||
/**
|
||||
* 菜单
|
||||
* - 对应具体页面
|
||||
*/
|
||||
public static final int PAGE = 2;
|
||||
|
||||
/**
|
||||
* 按钮
|
||||
* - 页面内的操作按钮
|
||||
*/
|
||||
public static final int BUTTON = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单可见性常量
|
||||
*/
|
||||
public static final class MenuVisibility {
|
||||
/**
|
||||
* 隐藏
|
||||
*/
|
||||
public static final int HIDDEN = 0;
|
||||
|
||||
/**
|
||||
* 可见
|
||||
*/
|
||||
public static final int VISIBLE = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
* - 防止实例化
|
||||
*/
|
||||
private AppConstants() {
|
||||
// 防止实例化
|
||||
}
|
||||
}
|
||||
103
src/main/java/com/aisi/template/constants/SecurityConstants.java
Normal file
103
src/main/java/com/aisi/template/constants/SecurityConstants.java
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.aisi.template.constants;
|
||||
|
||||
/**
|
||||
* 安全相关常量类
|
||||
* 定义系统中的安全相关常量
|
||||
*
|
||||
* 主要分类:
|
||||
* 1. Token 相关:Token 前缀、过期时间
|
||||
* 2. Redis 键前缀:黑名单、刷新令牌、限流、登录尝试
|
||||
* 3. 安全配置:最大失败次数、锁定时长
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class SecurityConstants {
|
||||
|
||||
/**
|
||||
* JWT Token 请求头名称
|
||||
* - HTTP 请求头:Authorization
|
||||
* - 示例:Authorization: Bearer {token}
|
||||
*/
|
||||
public static final String TOKEN_HEADER = "Authorization";
|
||||
|
||||
/**
|
||||
* JWT Token 前缀
|
||||
* - Bearer 认证方案
|
||||
* - 示例:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
*/
|
||||
public static final String TOKEN_PREFIX = "Bearer ";
|
||||
|
||||
/**
|
||||
* JWT Token 类型
|
||||
* - 用于文档标识
|
||||
* - 值:Bearer
|
||||
*/
|
||||
public static final String TOKEN_TYPE = "Bearer";
|
||||
|
||||
/**
|
||||
* Token 黑名单 Redis 键前缀
|
||||
* - 格式:token:blacklist:{jti}
|
||||
* - jti:JWT ID(JWT 唯一标识)
|
||||
* - 示例:token:blacklist:abc123-def456
|
||||
*/
|
||||
public static final String REDIS_KEY_BLACKLIST_PREFIX = "token:blacklist:";
|
||||
|
||||
/**
|
||||
* Refresh Token Redis 键前缀
|
||||
* - 格式:refresh_token:{tokenHash}
|
||||
* - tokenHash:Token 的 SHA-256 哈希值
|
||||
* - 示例:refresh_token:a1b2c3...
|
||||
*/
|
||||
public static final String REDIS_KEY_REFRESH_TOKEN_PREFIX = "refresh_token:";
|
||||
|
||||
/**
|
||||
* 限流 Redis 键前缀
|
||||
* - 格式:rate_limit:{type}:{identifier}
|
||||
* - type:ip(按 IP)、user(按用户)、global(全局)
|
||||
* - identifier:IP 地址、用户ID 等
|
||||
* - 示例:rate_limit:ip:192.168.1.1
|
||||
*/
|
||||
public static final String REDIS_KEY_RATE_LIMIT_PREFIX = "rate_limit:";
|
||||
|
||||
/**
|
||||
* 登录尝试 Redis 键前缀
|
||||
* - 格式:login_attempts:{username}
|
||||
* - 示例:login_attempts:user01
|
||||
*/
|
||||
public static final String REDIS_KEY_LOGIN_ATTEMPTS_PREFIX = "login_attempts:";
|
||||
|
||||
/**
|
||||
* 默认 Access Token 过期时间(秒)
|
||||
* - 1 小时 = 3600 秒
|
||||
*/
|
||||
public static final long DEFAULT_ACCESS_TOKEN_EXPIRATION = 3600;
|
||||
|
||||
/**
|
||||
* 默认 Refresh Token 过期时间(秒)
|
||||
* - 7 天 = 604800 秒
|
||||
*/
|
||||
public static final long DEFAULT_REFRESH_TOKEN_EXPIRATION = 604800;
|
||||
|
||||
/**
|
||||
* 最大登录失败次数
|
||||
* - 超过此次数后锁定账户
|
||||
* - 默认:5 次
|
||||
*/
|
||||
public static final int MAX_LOGIN_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* 默认账户锁定时长(分钟)
|
||||
* - 连续失败达到阈值后的锁定时长
|
||||
* - 默认:30 分钟
|
||||
*/
|
||||
public static final int DEFAULT_LOCK_DURATION_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* 私有构造函数
|
||||
* - 防止实例化
|
||||
*/
|
||||
private SecurityConstants() {
|
||||
// 防止实例化
|
||||
}
|
||||
}
|
||||
213
src/main/java/com/aisi/template/controller/MenuController.java
Normal file
213
src/main/java/com/aisi/template/controller/MenuController.java
Normal file
@@ -0,0 +1,213 @@
|
||||
package com.aisi.template.controller;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.CustomUserDetails;
|
||||
import com.aisi.template.domain.dto.MenuDto;
|
||||
import com.aisi.template.domain.vo.MenuVo;
|
||||
import com.aisi.template.service.SysMenuService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单管理控制器
|
||||
* 提供菜单的增删改查和树形结构接口
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 菜单基本操作:创建、更新、删除、查询
|
||||
* 2. 菜单树结构:获取完整的菜单树
|
||||
* 3. 用户菜单:根据用户角色获取可见菜单
|
||||
* 4. 层级查询:根据父节点查询子菜单
|
||||
*
|
||||
* 权限说明:
|
||||
* - 所有接口都需要登录
|
||||
* - 管理接口需要相应权限(如 menu:create、menu:update)
|
||||
* - 用户菜单接口登录后即可访问
|
||||
*
|
||||
* 菜单类型:
|
||||
* - 1:目录(一级菜单)
|
||||
* - 2:页面(二级菜单)
|
||||
* - 3:按钮(页面内的操作按钮)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Tag(name = "菜单管理", description = "菜单的增删改查和树形结构接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/menus")
|
||||
@RequiredArgsConstructor
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class MenuController {
|
||||
|
||||
/**
|
||||
* 菜单服务
|
||||
* 处理菜单相关的业务逻辑
|
||||
*/
|
||||
private final SysMenuService menuService;
|
||||
|
||||
/**
|
||||
* 创建菜单
|
||||
* 步骤:
|
||||
* 1. 校验父菜单是否存在
|
||||
* 2. 校验菜单名称是否重复
|
||||
* 3. 创建菜单并保存
|
||||
*
|
||||
* 权限:需要 menu:create 权限
|
||||
*
|
||||
* @param menuDto 菜单信息(名称、类型、父节点、路径等)
|
||||
* @return 创建后的菜单信息
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建菜单", description = "创建新的菜单项")
|
||||
@PreAuthorize("hasAuthority('menu:create')")
|
||||
public RestBean<MenuVo> create(@Valid @RequestBody MenuDto menuDto) {
|
||||
MenuVo menu = menuService.create(menuDto);
|
||||
return RestBean.success(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 如果修改父节点,检查新父节点是否存在
|
||||
* 3. 更新菜单信息
|
||||
*
|
||||
* 权限:需要 menu:update 权限
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @param menuDto 菜单信息
|
||||
* @return 更新后的菜单信息
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新菜单", description = "更新菜单的名称、路径等信息")
|
||||
@PreAuthorize("hasAuthority('menu:update')")
|
||||
public RestBean<MenuVo> update(@PathVariable Long id, @Valid @RequestBody MenuDto menuDto) {
|
||||
MenuVo menu = menuService.update(id, menuDto);
|
||||
return RestBean.success(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 检查是否有子菜单
|
||||
* 3. 删除菜单(子菜单会一并删除)
|
||||
*
|
||||
* 权限:需要 menu:delete 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 删除目录会一并删除其下所有子菜单
|
||||
* - 建议先删除子菜单再删除父菜单
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @return 成功响应
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除菜单", description = "删除指定的菜单及其子菜单")
|
||||
@PreAuthorize("hasAuthority('menu:delete')")
|
||||
public RestBean<Void> delete(@PathVariable Long id) {
|
||||
menuService.delete(id);
|
||||
return RestBean.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取菜单
|
||||
* 步骤:
|
||||
* 1. 查询菜单基本信息
|
||||
* 2. 返回详细信息
|
||||
*
|
||||
* 权限:需要 menu:read 权限
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @return 菜单详细信息
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取菜单详情", description = "根据ID获取菜单详细信息")
|
||||
@PreAuthorize("hasAuthority('menu:read')")
|
||||
public RestBean<MenuVo> getById(@PathVariable Long id) {
|
||||
MenuVo menu = menuService.getById(id);
|
||||
return RestBean.success(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整菜单树
|
||||
* 步骤:
|
||||
* 1. 查询所有菜单
|
||||
* 2. 构建树形结构(父子关系)
|
||||
* 3. 返回完整的菜单树
|
||||
*
|
||||
* 权限:需要 menu:list 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 返回的是完整树形结构,包含目录、页面、按钮
|
||||
* - 按排序字段排序
|
||||
*
|
||||
* @return 菜单树列表
|
||||
*/
|
||||
@GetMapping("/tree")
|
||||
@Operation(summary = "获取菜单树", description = "获取完整的菜单树形结构")
|
||||
@PreAuthorize("hasAuthority('menu:list')")
|
||||
public RestBean<List<MenuVo>> getMenuTree() {
|
||||
List<MenuVo> menus = menuService.getMenuTree();
|
||||
return RestBean.success(menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的菜单
|
||||
* 步骤:
|
||||
* 1. 从认证信息中获取用户ID
|
||||
* 2. 查询用户的角色
|
||||
* 3. 查询角色关联的菜单
|
||||
* 4. 构建树形结构(只包含目录和页面)
|
||||
* 5. 过滤不可见和禁用的菜单
|
||||
*
|
||||
* 注意:
|
||||
* - 只返回目录和页面,不包含按钮
|
||||
* - 根据用户角色动态生成
|
||||
* - 用于前端动态渲染导航菜单
|
||||
*
|
||||
* @param authentication Spring Security 认证信息
|
||||
* @return 用户可见的菜单树
|
||||
*/
|
||||
@GetMapping("/user")
|
||||
@Operation(summary = "获取当前用户菜单", description = "获取当前用户可见的菜单树")
|
||||
public RestBean<List<MenuVo>> getUserMenus(Authentication authentication) {
|
||||
// 1. 从认证信息获取用户ID
|
||||
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
|
||||
Long userId = userDetails.getId();
|
||||
// 2. 查询用户菜单(已过滤)
|
||||
List<MenuVo> menus = menuService.getUserMenus(userId);
|
||||
return RestBean.success(menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据父节点ID获取子菜单
|
||||
* 步骤:
|
||||
* 1. 查询指定父节点下的所有直接子菜单
|
||||
* 2. 按排序字段排序
|
||||
* 3. 返回列表
|
||||
*
|
||||
* 权限:需要 menu:list 权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 动态加载子菜单
|
||||
* - 前端树形控件展开时加载
|
||||
*
|
||||
* @param parentId 父节点ID(0 表示根节点)
|
||||
* @return 子菜单列表
|
||||
*/
|
||||
@GetMapping("/parent/{parentId}")
|
||||
@Operation(summary = "获取子菜单", description = "根据父节点ID获取直接子菜单")
|
||||
@PreAuthorize("hasAuthority('menu:list')")
|
||||
public RestBean<List<MenuVo>> getByParentId(@PathVariable Long parentId) {
|
||||
List<MenuVo> menus = menuService.getByParentId(parentId);
|
||||
return RestBean.success(menus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.aisi.template.controller;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.vo.PermissionVo;
|
||||
import com.aisi.template.service.SysPermissionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 权限管理控制器
|
||||
* 提供权限的查询接口
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 查询所有权限
|
||||
* 2. 根据ID获取权限详情
|
||||
* 3. 按资源类型查询权限
|
||||
* 4. 按操作类型查询权限
|
||||
*
|
||||
* 权限说明:
|
||||
* - 所有接口都需要登录
|
||||
* - 需要相应权限(如 permission:list)
|
||||
*
|
||||
* 权限编码规范:
|
||||
* - 格式:{资源}:{操作}
|
||||
* - 示例:user:create, role:update, menu:delete
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Tag(name = "权限管理", description = "系统权限查询接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/permissions")
|
||||
@RequiredArgsConstructor
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class PermissionController {
|
||||
|
||||
/**
|
||||
* 权限服务
|
||||
* 处理权限相关的业务逻辑
|
||||
*/
|
||||
private final SysPermissionService permissionService;
|
||||
|
||||
/**
|
||||
* 获取所有权限
|
||||
* 步骤:
|
||||
* 1. 查询所有状态为启用的权限
|
||||
* 2. 按资源和操作排序
|
||||
* 3. 返回列表
|
||||
*
|
||||
* 权限:需要 permission:list 权限
|
||||
*
|
||||
* @return 所有权限列表
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取所有权限", description = "获取系统中所有可用的权限")
|
||||
@PreAuthorize("hasAuthority('permission:list')")
|
||||
public RestBean<List<PermissionVo>> getAllPermissions() {
|
||||
List<PermissionVo> permissions = permissionService.getAllPermissions();
|
||||
return RestBean.success(permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取权限
|
||||
* 步骤:
|
||||
* 1. 查询权限基本信息
|
||||
* 2. 返回详细信息
|
||||
*
|
||||
* 权限:需要 permission:list 权限
|
||||
*
|
||||
* @param id 权限ID
|
||||
* @return 权限详细信息
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取权限详情", description = "根据ID获取权限详细信息")
|
||||
@PreAuthorize("hasAuthority('permission:list')")
|
||||
public RestBean<PermissionVo> getById(@PathVariable Long id) {
|
||||
PermissionVo permission = permissionService.getById(id);
|
||||
return RestBean.success(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按资源类型获取权限
|
||||
* 步骤:
|
||||
* 1. 根据资源类型(如 user、role)查询
|
||||
* 2. 返回该资源的所有操作权限
|
||||
*
|
||||
* 权限:需要 permission:list 权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 分配权限时,按资源类型展示
|
||||
* - 权限管理页面按资源分组显示
|
||||
*
|
||||
* @param resource 资源类型(如 user、role、permission、menu)
|
||||
* @return 该资源的所有权限
|
||||
*/
|
||||
@GetMapping("/resource/{resource}")
|
||||
@Operation(summary = "按资源获取权限", description = "获取指定资源的所有权限")
|
||||
@PreAuthorize("hasAuthority('permission:list')")
|
||||
public RestBean<List<PermissionVo>> getByResource(@PathVariable String resource) {
|
||||
List<PermissionVo> permissions = permissionService.getByResource(resource);
|
||||
return RestBean.success(permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按操作类型获取权限
|
||||
* 步骤:
|
||||
* 1. 根据操作类型(如 create、update、delete)查询
|
||||
* 2. 返回该操作的所有资源权限
|
||||
*
|
||||
* 权限:需要 permission:list 权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 查询所有创建权限
|
||||
* - 查询所有删除权限
|
||||
*
|
||||
* @param action 操作类型(create、read、update、delete、list)
|
||||
* @return 该操作的所有权限
|
||||
*/
|
||||
@GetMapping("/action/{action}")
|
||||
@Operation(summary = "按操作获取权限", description = "获取指定操作的所有权限")
|
||||
@PreAuthorize("hasAuthority('permission:list')")
|
||||
public RestBean<List<PermissionVo>> getByAction(@PathVariable String action) {
|
||||
List<PermissionVo> permissions = permissionService.getByAction(action);
|
||||
return RestBean.success(permissions);
|
||||
}
|
||||
}
|
||||
229
src/main/java/com/aisi/template/controller/RoleController.java
Normal file
229
src/main/java/com/aisi/template/controller/RoleController.java
Normal file
@@ -0,0 +1,229 @@
|
||||
package com.aisi.template.controller;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.dto.RoleDto;
|
||||
import com.aisi.template.domain.dto.RoleQueryDto;
|
||||
import com.aisi.template.domain.dto.PageResult;
|
||||
import com.aisi.template.domain.vo.RoleVo;
|
||||
import com.aisi.template.service.SysRoleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色管理控制器
|
||||
* 提供角色的增删改查和权限分配接口
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 角色基本操作:创建、更新、删除、查询
|
||||
* 2. 权限管理:为角色分配权限、获取角色权限
|
||||
* 3. 分页查询:支持条件查询和分页
|
||||
*
|
||||
* 权限说明:
|
||||
* - 所有接口都需要登录
|
||||
* - 需要相应的角色权限(如 role:create、role:update)
|
||||
*
|
||||
* 角色编码规范:
|
||||
* - ROLE_SUPER_ADMIN:超级管理员
|
||||
* - ROLE_ADMIN:管理员
|
||||
* - ROLE_USER:普通用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Tag(name = "角色管理", description = "角色的增删改查和权限分配接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/roles")
|
||||
@RequiredArgsConstructor
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class RoleController {
|
||||
|
||||
/**
|
||||
* 角色服务
|
||||
* 处理角色相关的业务逻辑
|
||||
*/
|
||||
private final SysRoleService roleService;
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* 步骤:
|
||||
* 1. 校验角色编码是否已存在
|
||||
* 2. 校验角色名称是否合法
|
||||
* 3. 创建角色并保存
|
||||
*
|
||||
* 权限:需要 role:create 权限
|
||||
*
|
||||
* @param roleDto 角色信息(编码、名称、描述等)
|
||||
* @return 创建后的角色信息
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建角色", description = "创建新的角色,需要提供角色编码和名称")
|
||||
@PreAuthorize("hasAuthority('role:create')")
|
||||
public RestBean<RoleVo> create(@Valid @RequestBody RoleDto roleDto) {
|
||||
RoleVo role = roleService.create(roleDto);
|
||||
return RestBean.success(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 如果修改角色编码,检查新编码是否冲突
|
||||
* 3. 更新角色信息
|
||||
*
|
||||
* 权限:需要 role:update 权限
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @param roleDto 角色信息
|
||||
* @return 更新后的角色信息
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新角色", description = "更新角色的名称、描述等信息")
|
||||
@PreAuthorize("hasAuthority('role:update')")
|
||||
public RestBean<RoleVo> update(@PathVariable Long id, @Valid @RequestBody RoleDto roleDto) {
|
||||
RoleVo role = roleService.update(id, roleDto);
|
||||
return RestBean.success(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 检查角色是否已分配给用户
|
||||
* 3. 删除角色(关联关系会自动级联删除)
|
||||
*
|
||||
* 权限:需要 role:delete 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 系统内置角色(如 ROLE_ADMIN)不允许删除
|
||||
* - 已分配给用户的角色需要先解除关联
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 成功响应
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除角色", description = "删除指定的角色")
|
||||
@PreAuthorize("hasAuthority('role:delete')")
|
||||
public RestBean<Void> delete(@PathVariable Long id) {
|
||||
roleService.delete(id);
|
||||
return RestBean.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取角色
|
||||
* 步骤:
|
||||
* 1. 查询角色基本信息
|
||||
* 2. 加载关联的权限列表
|
||||
* 3. 返回详细信息
|
||||
*
|
||||
* 权限:需要 role:read 权限
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 角色详细信息(包含权限列表)
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取角色详情", description = "根据ID获取角色及其权限信息")
|
||||
@PreAuthorize("hasAuthority('role:read')")
|
||||
public RestBean<RoleVo> getById(@PathVariable Long id) {
|
||||
RoleVo role = roleService.getById(id);
|
||||
return RestBean.success(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有角色
|
||||
* 步骤:
|
||||
* 1. 查询所有角色
|
||||
* 2. 加载每个角色的权限
|
||||
* 3. 返回列表
|
||||
*
|
||||
* 权限:需要 role:list 权限
|
||||
*
|
||||
* @return 所有角色列表(包含权限)
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取所有角色", description = "获取系统中所有角色及其权限")
|
||||
@PreAuthorize("hasAuthority('role:list')")
|
||||
public RestBean<List<RoleVo>> getAllRoles() {
|
||||
List<RoleVo> roles = roleService.getAllRoles();
|
||||
return RestBean.success(roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询角色
|
||||
* 步骤:
|
||||
* 1. 根据查询条件构建动态查询
|
||||
* 2. 支持按角色编码、名称、状态筛选
|
||||
* 3. 返回分页结果
|
||||
*
|
||||
* 权限:需要 role:list 权限
|
||||
*
|
||||
* @param queryDto 查询条件(角色编码、名称、状态)
|
||||
* @param page 页码(从 0 开始)
|
||||
* @param size 每页大小
|
||||
* @return 分页角色列表
|
||||
*/
|
||||
@PostMapping("/query")
|
||||
@Operation(summary = "分页查询角色", description = "支持条件筛选和分页")
|
||||
@PreAuthorize("hasAuthority('role:list')")
|
||||
public RestBean<PageResult<RoleVo>> queryRoles(
|
||||
@RequestBody RoleQueryDto queryDto,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
PageResult<RoleVo> result = roleService.queryRoles(queryDto, page, size);
|
||||
return RestBean.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为角色分配权限
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 检查所有权限ID是否存在
|
||||
* 3. 清空角色原有权限
|
||||
* 4. 添加新的权限
|
||||
*
|
||||
* 权限:需要 role:assign-permission 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 权限变更后需要用户重新登录才能生效
|
||||
* - 建议在非高峰期操作
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @param permissionIds 权限ID列表
|
||||
* @return 成功响应
|
||||
*/
|
||||
@PostMapping("/{id}/permissions")
|
||||
@Operation(summary = "为角色分配权限", description = "批量分配权限给角色")
|
||||
@PreAuthorize("hasAuthority('role:assign-permission')")
|
||||
public RestBean<Void> assignPermissions(
|
||||
@PathVariable Long id,
|
||||
@RequestBody List<Long> permissionIds) {
|
||||
roleService.assignPermissions(id, permissionIds);
|
||||
return RestBean.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色的权限ID列表
|
||||
* 步骤:
|
||||
* 1. 查询角色基本信息
|
||||
* 2. 提取所有关联的权限ID
|
||||
* 3. 返回ID列表
|
||||
*
|
||||
* 权限:需要 role:read 权限
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 权限ID列表
|
||||
*/
|
||||
@GetMapping("/{id}/permissions")
|
||||
@Operation(summary = "获取角色权限", description = "获取角色拥有的所有权限ID")
|
||||
@PreAuthorize("hasAuthority('role:read')")
|
||||
public RestBean<List<Long>> getRolePermissionIds(@PathVariable Long id) {
|
||||
List<Long> permissionIds = roleService.getRolePermissionIds(id);
|
||||
return RestBean.success(permissionIds);
|
||||
}
|
||||
}
|
||||
@@ -4,84 +4,352 @@ import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.dto.PageResult;
|
||||
import com.aisi.template.domain.dto.PasswordResetConfirmDto;
|
||||
import com.aisi.template.domain.dto.PasswordResetRequestDto;
|
||||
import com.aisi.template.domain.dto.RefreshTokenDto;
|
||||
import com.aisi.template.domain.dto.UserDto;
|
||||
import com.aisi.template.domain.dto.UserQueryDto;
|
||||
import com.aisi.template.domain.dto.UserRoleUpdateDto;
|
||||
import com.aisi.template.domain.dto.UserStatusUpdateDto;
|
||||
import com.aisi.template.domain.vo.LoginResponseVo;
|
||||
import com.aisi.template.domain.vo.UserVo;
|
||||
import com.aisi.template.service.PasswordResetService;
|
||||
import com.aisi.template.service.TokenService;
|
||||
import com.aisi.template.service.UserService;
|
||||
import com.aisi.template.utils.JwtUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户管理控制器
|
||||
* 提供用户注册、登录、信息管理等相关接口
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 用户认证:注册、登录、登出、Token 刷新
|
||||
* 2. 密码管理:密码重置(邮件验证码)
|
||||
* 3. 用户信息查询:获取当前用户信息
|
||||
* 4. 用户管理:用户列表查询(需要权限)、状态更新、角色分配
|
||||
*
|
||||
* 权限说明:
|
||||
* - 用户注册、登录、密码重置:无需认证
|
||||
* - 获取用户信息:需要登录
|
||||
* - 用户管理接口:需要相应权限(如 user:list、user:update)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/user")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "用户接口")
|
||||
@Tag(name = "用户接口", description = "用户注册、登录、信息管理等相关接口")
|
||||
public class UserController {
|
||||
|
||||
/**
|
||||
* 用户服务
|
||||
* 处理用户相关的业务逻辑
|
||||
*/
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 密码重置服务
|
||||
* 处理密码重置的业务逻辑
|
||||
*/
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
/**
|
||||
* Token 服务
|
||||
* 处理 Refresh Token 和黑名单管理
|
||||
*/
|
||||
private final TokenService tokenService;
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
* 用于生成和解析 JWT Token
|
||||
*/
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* 步骤:
|
||||
* 1. 从 SecurityContext 获取当前用户信息
|
||||
* 2. 返回用户详细信息
|
||||
*
|
||||
* 权限:需要登录(Bearer Token)
|
||||
*
|
||||
* @return 用户信息视图对象
|
||||
*/
|
||||
@GetMapping("info")
|
||||
@Operation(summary = "用户信息")
|
||||
@Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
public RestBean<UserVo> getUserInfo() {
|
||||
return userService.getUserInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 步骤:
|
||||
* 1. 校验用户名、邮箱是否已存在
|
||||
* 2. 校验密码强度(必须包含大小写字母、数字、特殊字符)
|
||||
* 3. 创建用户并分配默认角色(ROLE_USER)
|
||||
* 4. 生成 Access Token 和 Refresh Token
|
||||
*
|
||||
* 注意:
|
||||
* - 密码会使用 BCrypt 加密存储
|
||||
* - 新用户默认分配 ROLE_USER 角色
|
||||
*
|
||||
* @param userDto 用户注册信息(用户名、密码、邮箱)
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
@PostMapping("register")
|
||||
@Operation(summary = "用户注册")
|
||||
public RestBean<String> register(@Valid @RequestBody UserDto userDto) {
|
||||
@Operation(summary = "用户注册", description = "创建新用户账号并返回登录凭证")
|
||||
public RestBean<LoginResponseVo> register(@Valid @RequestBody UserDto userDto) {
|
||||
return userService.register(userDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 步骤:
|
||||
* 1. 验证用户名和密码
|
||||
* 2. 检查账户状态(是否被禁用、锁定)
|
||||
* 3. 记录登录失败次数(超过阈值则锁定账户)
|
||||
* 4. 生成 Access Token 和 Refresh Token
|
||||
* 5. 发送登录消息到 MQ
|
||||
*
|
||||
* 注意:
|
||||
* - 连续登录失败 5 次会锁定账户 30 分钟
|
||||
* - 密码错误也会被记录
|
||||
*
|
||||
* @param userDto 登录信息(用户名、密码)
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
@PostMapping("login")
|
||||
@Operation(summary = "用户登录")
|
||||
RestBean<String> login(@Valid @RequestBody UserDto userDto) {
|
||||
@Operation(summary = "用户登录", description = "使用用户名密码登录,返回访问令牌")
|
||||
RestBean<LoginResponseVo> login(@Valid @RequestBody UserDto userDto) {
|
||||
return userService.login(userDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* 步骤:
|
||||
* 1. 从请求头获取当前 Token
|
||||
* 2. 提取 Token 的 JTI(JWT ID)
|
||||
* 3. 将 Token 加入黑名单(剩余过期时间内有效)
|
||||
* 4. 撤销用户所有 Refresh Token
|
||||
*
|
||||
* 权限:需要登录(Bearer Token)
|
||||
*
|
||||
* @param request HTTP 请求对象(用于获取 Token)
|
||||
* @return 成功响应
|
||||
*/
|
||||
@PostMapping("logout")
|
||||
@Operation(summary = "用户登出", description = "退出登录,使当前 Token 失效")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
public RestBean<Void> logout(HttpServletRequest request) {
|
||||
// 1. 从请求头提取 Token
|
||||
String token = extractToken(request);
|
||||
if (token != null) {
|
||||
// 2. 提取 Token 的 JTI(JWT 唯一标识)
|
||||
String jti = jwtUtil.extractJti(token);
|
||||
if (jti != null) {
|
||||
// 3. 计算剩余过期时间
|
||||
long remainingSeconds = getRemainingExpiration(token);
|
||||
// 4. 将 Token 加入黑名单(过期后自动删除)
|
||||
tokenService.addTokenToBlacklist(jti, remainingSeconds);
|
||||
}
|
||||
|
||||
// 5. 撤销当前用户的所有 Refresh Token
|
||||
Long userId = jwtUtil.extractUserId(token);
|
||||
if (userId != null) {
|
||||
tokenService.revokeAllUserTokens(userId);
|
||||
}
|
||||
}
|
||||
return RestBean.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
* 步骤:
|
||||
* 1. 验证 Refresh Token 是否有效
|
||||
* 2. 生成新的 Access Token
|
||||
* 3. 生成新的 Refresh Token(Token 轮换机制)
|
||||
* 4. 撤销旧的 Refresh Token
|
||||
*
|
||||
* 注意:
|
||||
* - Refresh Token 使用一次后即失效
|
||||
* - 使用 Refresh Token 轮换提高安全性
|
||||
*
|
||||
* @param refreshTokenDto 刷新令牌请求
|
||||
* @return 新的登录响应(包含新的 Token)
|
||||
*/
|
||||
@PostMapping("refresh")
|
||||
@Operation(summary = "刷新访问令牌", description = "使用 Refresh Token 获取新的 Access Token")
|
||||
public RestBean<LoginResponseVo> refreshToken(@Valid @RequestBody RefreshTokenDto refreshTokenDto) {
|
||||
LoginResponseVo response = tokenService.refreshToken(refreshTokenDto.getRefreshToken());
|
||||
return RestBean.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码
|
||||
* 步骤:
|
||||
* 1. 校验邮箱格式
|
||||
* 2. 检查请求冷却时间(防止频繁请求)
|
||||
* 3. 生成 6 位数字验证码
|
||||
* 4. 发送验证码到用户邮箱
|
||||
* 5. 验证码哈希后存储到 Redis(10分钟有效)
|
||||
*
|
||||
* 注意:
|
||||
* - 同一邮箱 60 秒内只能请求一次
|
||||
* - 验证码最多尝试 5 次
|
||||
*
|
||||
* @param requestDto 密码重置请求(邮箱)
|
||||
* @return 成功响应
|
||||
*/
|
||||
@PostMapping("password-reset/request")
|
||||
@Operation(summary = "发送找回密码验证码")
|
||||
@Operation(summary = "发送密码重置验证码", description = "发送验证码到用户邮箱")
|
||||
public RestBean<Void> sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) {
|
||||
return passwordResetService.sendResetCode(requestDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用验证码重置密码
|
||||
* 步骤:
|
||||
* 1. 验证邮箱和验证码是否匹配
|
||||
* 2. 验证验证码是否过期
|
||||
* 3. 验证验证码尝试次数
|
||||
* 4. 校验新密码强度
|
||||
* 5. 更新用户密码
|
||||
* 6. 删除已使用的验证码
|
||||
*
|
||||
* @param confirmDto 密码重置确认(邮箱、验证码、新密码)
|
||||
* @return 成功响应
|
||||
*/
|
||||
@PostMapping("password-reset/confirm")
|
||||
@Operation(summary = "验证码重置密码")
|
||||
@Operation(summary = "验证码重置密码", description = "使用邮箱验证码重置用户密码")
|
||||
public RestBean<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) {
|
||||
return passwordResetService.resetPassword(confirmDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取用户列表
|
||||
* 步骤:
|
||||
* 1. 根据查询条件构建动态查询
|
||||
* 2. 支持按用户名/邮箱模糊搜索
|
||||
* 3. 支持按状态筛选
|
||||
* 4. 返回分页结果
|
||||
*
|
||||
* 权限:需要 user:list 权限
|
||||
*
|
||||
* @param queryDto 查询条件(关键词、状态等)
|
||||
* @return 分页用户列表
|
||||
*/
|
||||
@GetMapping("list")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "分页获取用户列表")
|
||||
@PreAuthorize("hasAuthority('user:list')")
|
||||
@Operation(summary = "分页获取用户列表", description = "查询用户列表,支持分页和条件筛选")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
|
||||
return userService.getUserList(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* 步骤:
|
||||
* 1. 检查用户是否存在
|
||||
* 2. 禁止修改当前登录用户的状态
|
||||
* 3. 更新用户状态(启用/禁用)
|
||||
*
|
||||
* 权限:需要 user:update 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 不能禁用当前登录的用户
|
||||
* - 禁用后用户无法登录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 状态更新请求
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
@PutMapping("{userId}/status")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "更新用户状态")
|
||||
@PreAuthorize("hasAuthority('user:update')")
|
||||
@Operation(summary = "更新用户状态", description = "启用或禁用用户账号")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
public RestBean<UserVo> updateUserStatus(@PathVariable Long userId,
|
||||
@Valid @RequestBody UserStatusUpdateDto updateDto) {
|
||||
return userService.updateUserStatus(userId, updateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户角色
|
||||
* 步骤:
|
||||
* 1. 检查用户是否存在
|
||||
* 2. 禁止修改当前登录用户的角色
|
||||
* 3. 验证角色ID是否存在
|
||||
* 4. 更新用户角色关系
|
||||
*
|
||||
* 权限:需要 user:update 权限
|
||||
*
|
||||
* 注意:
|
||||
* - 不能修改当前登录用户的角色
|
||||
* - 角色变更后需要重新登录才能生效
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 角色更新请求(角色ID列表)
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
@PutMapping("{userId}/role")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "更新用户角色")
|
||||
@PreAuthorize("hasAuthority('user:update')")
|
||||
@Operation(summary = "更新用户角色", description = "为用户分配或移除角色")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
public RestBean<UserVo> updateUserRole(@PathVariable Long userId,
|
||||
@Valid @RequestBody UserRoleUpdateDto updateDto) {
|
||||
return userService.updateUserRole(userId, updateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTTP 请求中提取 JWT Token
|
||||
* 步骤:
|
||||
* 1. 从 Authorization 头获取值
|
||||
* 2. 验证格式(Bearer 前缀)
|
||||
* 3. 提取 Token 部分(去掉 "Bearer " 前缀)
|
||||
*
|
||||
* @param request HTTP 请求对象
|
||||
* @return JWT Token 字符串,无效返回 null
|
||||
*/
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
// 1. 获取 Authorization 头
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization == null || !authorization.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
// 2. 去掉 "Bearer " 前缀(注意空格)
|
||||
return authorization.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Token 剩余有效时间(秒)
|
||||
* 步骤:
|
||||
* 1. 提取 Token 过期时间
|
||||
* 2. 计算与当前时间的差值
|
||||
* 3. 转换为秒并确保不为负数
|
||||
*
|
||||
* @param token JWT Token 字符串
|
||||
* @return 剩余秒数,已过期返回 0
|
||||
*/
|
||||
private long getRemainingExpiration(String token) {
|
||||
try {
|
||||
// 1. 获取过期时间
|
||||
Date expiration = jwtUtil.extractExpiration(token);
|
||||
// 2. 计算剩余毫秒数
|
||||
long remainingMillis = expiration.getTime() - System.currentTimeMillis();
|
||||
// 3. 转换为秒(至少为 0)
|
||||
return Math.max(0, remainingMillis / 1000);
|
||||
} catch (Exception e) {
|
||||
// Token 解析失败,返回 0
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,57 +5,159 @@ import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 自定义用户详情类
|
||||
* 实现 Spring Security 的 UserDetails 接口
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 用户认证:提供用户信息(用户名、密码)
|
||||
* 2. 用户授权:提供用户权限和角色
|
||||
* 3. 用户状态:提供用户账户状态信息
|
||||
*
|
||||
* 设计说明:
|
||||
* - 扩展了标准 UserDetails,增加了用户ID和角色列表
|
||||
* - 用于 Spring Security 的认证和授权流程
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class CustomUserDetails implements UserDetails {
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@Getter
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final Collection<? extends GrantedAuthority> authorities;
|
||||
private final boolean enabled;
|
||||
@Getter
|
||||
private final String role;
|
||||
|
||||
public CustomUserDetails(Long id, String username, String password, Collection<? extends GrantedAuthority> authorities, boolean enabled, String role) {
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
private final String username;
|
||||
|
||||
/**
|
||||
* 密码(加密后的)
|
||||
*/
|
||||
private final String password;
|
||||
|
||||
/**
|
||||
* 用户权限列表
|
||||
* - 包含角色和权限
|
||||
* - 格式:ROLE_XXX(角色)或 XXX:YYY(权限)
|
||||
*/
|
||||
private final Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
/**
|
||||
* 用户是否启用
|
||||
* - true:启用
|
||||
* - false:禁用
|
||||
*/
|
||||
private final boolean enabled;
|
||||
|
||||
/**
|
||||
* 用户角色列表
|
||||
* - 包含角色编码
|
||||
* - 例如:ROLE_USER, ROLE_ADMIN
|
||||
*/
|
||||
@Getter
|
||||
private final Set<String> roles;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @param authorities 权限列表
|
||||
* @param enabled 是否启用
|
||||
* @param roles 角色列表
|
||||
*/
|
||||
public CustomUserDetails(Long id, String username, String password,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
boolean enabled, Set<String> roles) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.authorities = authorities;
|
||||
this.enabled = enabled;
|
||||
this.role = role;
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户名
|
||||
*
|
||||
* @return 用户名
|
||||
*/
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取密码
|
||||
*
|
||||
* @return 密码
|
||||
*/
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限列表
|
||||
*
|
||||
* @return 权限列表
|
||||
*/
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户是否未过期
|
||||
* 说明:
|
||||
* - 当前实现返回 true(账户永不过期)
|
||||
* - 可根据业务需求扩展
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户是否未锁定
|
||||
* 说明:
|
||||
* - 当前实现返回 true(不在此处判断锁定)
|
||||
* - 锁定状态由 User.isLocked() 判断
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码是否未过期
|
||||
* 说明:
|
||||
* - 当前实现返回 true(密码永不过期)
|
||||
* - 可根据业务需求扩展(如密码过期策略)
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户是否启用
|
||||
*
|
||||
* @return 是否启用
|
||||
*/
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
|
||||
@@ -6,23 +6,62 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 通用的 REST API 响应封装类
|
||||
* 统一 API 响应格式
|
||||
*
|
||||
* 响应格式:
|
||||
* <pre>
|
||||
* {
|
||||
* "code": 200,
|
||||
* "message": "操作成功",
|
||||
* "data": {...}
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 统一响应格式:所有接口返回相同的结构
|
||||
* 2. 成功响应:提供多种成功响应构造方法
|
||||
* 3. 失败响应:提供多种失败响应构造方法
|
||||
*
|
||||
* @param <V> 返回数据的类型
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class RestBean<V> {
|
||||
|
||||
/** 状态码 */
|
||||
/**
|
||||
* 状态码
|
||||
* - 200:成功
|
||||
* - 400:客户端错误
|
||||
* - 401:未登录
|
||||
* - 403:无权限
|
||||
* - 404:资源不存在
|
||||
* - 500:服务器错误
|
||||
*/
|
||||
private int code;
|
||||
/** 提示消息 */
|
||||
|
||||
/**
|
||||
* 提示消息
|
||||
* - 成功时返回成功信息
|
||||
* - 失败时返回错误信息
|
||||
*/
|
||||
private String message;
|
||||
/** 具体数据 */
|
||||
|
||||
/**
|
||||
* 具体数据
|
||||
* - 成功时返回业务数据
|
||||
* - 失败时可能返回 null 或错误详情
|
||||
*/
|
||||
private V data;
|
||||
|
||||
/**
|
||||
* 成功响应(默认使用 {@link RestCode#SUCCESS})
|
||||
* 成功响应(默认使用 RestCode#SUCCESS)
|
||||
* 说明:
|
||||
* - 状态码:200
|
||||
* - 消息:"操作成功"
|
||||
* - 返回业务数据
|
||||
*
|
||||
* @param data 返回的数据
|
||||
* @param <V> 泛型参数
|
||||
@@ -32,6 +71,19 @@ public class RestBean<V> {
|
||||
return success(RestCode.SUCCESS, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应(无数据返回)
|
||||
* 使用场景:
|
||||
* - 删除操作
|
||||
* - 更新操作
|
||||
*
|
||||
* @param <V> 泛型参数
|
||||
* @return RestBean 包装对象
|
||||
*/
|
||||
public static <V> RestBean<V> success() {
|
||||
return success(RestCode.SUCCESS, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应(指定 RestCode 和数据)
|
||||
*
|
||||
@@ -46,6 +98,9 @@ public class RestBean<V> {
|
||||
|
||||
/**
|
||||
* 成功响应(只返回状态码和消息,不带数据)
|
||||
* 使用场景:
|
||||
* - 删除操作(不需要返回数据)
|
||||
* - 更新操作(不需要返回数据)
|
||||
*
|
||||
* @param restCode 状态码枚举
|
||||
* @param <V> 泛型参数
|
||||
@@ -69,7 +124,10 @@ public class RestBean<V> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(默认使用 {@link RestCode#FAILURE})
|
||||
* 失败响应(默认使用 RestCode#FAILURE)
|
||||
* 说明:
|
||||
* - 状态码:400
|
||||
* - 消息:"操作失败"
|
||||
*
|
||||
* @param data 返回的数据
|
||||
* @param <V> 泛型参数
|
||||
@@ -115,4 +173,16 @@ public class RestBean<V> {
|
||||
return new RestBean<>(code, message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(自定义 code 和 message,无数据)
|
||||
*
|
||||
* @param code 状态码
|
||||
* @param message 提示消息
|
||||
* @param <V> 泛型参数
|
||||
* @return RestBean 包装对象
|
||||
*/
|
||||
public static <V> RestBean<V> failure(int code, String message) {
|
||||
return new RestBean<>(code, message, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,26 +7,214 @@ import lombok.ToString;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API 状态码枚举
|
||||
* 定义所有 API 响应的状态码和消息
|
||||
*
|
||||
* 状态码分类:
|
||||
* - 通用错误码:200-599
|
||||
* - 认证相关:1000-1099
|
||||
* - 用户相关:1100-1199
|
||||
* - 角色相关:1200-1299
|
||||
* - 权限相关:1300-1399
|
||||
* - 菜单相关:1400-1499
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
public enum RestCode {
|
||||
|
||||
SUCCESS(200,"操作成功"),
|
||||
FAILURE(400,"操作失败"),
|
||||
USERNAME_OR_PASSWORD_ERROR(402,"用户名或密码错误"),
|
||||
NO_LOGIN(401,"用户未登录"),
|
||||
UNAUTHORIZED(403,"未授权"),
|
||||
TOKEN_EXPIRE(403, "token已过期"),
|
||||
TOKEN_EMPTY(403, "token不能为空"),
|
||||
TOKEN_INVALID(403, "token非法"),
|
||||
SYSTEM_ERROR(500,"系统错误,请联系管理员" ),
|
||||
DATA_NOT_FOUND(404,"数据不存在"),
|
||||
DATA_ALREADY_FOUND(409,"数据已存在"),
|
||||
METHOD_NOT_SUPPORT(405,"不支持该请求方法");
|
||||
// ==================== 通用错误码 ====================
|
||||
|
||||
/**
|
||||
* 操作成功
|
||||
* 状态码:200
|
||||
*/
|
||||
SUCCESS(200, "操作成功"),
|
||||
|
||||
/**
|
||||
* 操作失败
|
||||
* 状态码:400
|
||||
*/
|
||||
FAILURE(400, "操作失败"),
|
||||
|
||||
/**
|
||||
* 用户未登录
|
||||
* 状态码:401
|
||||
*/
|
||||
NO_LOGIN(401, "用户未登录"),
|
||||
|
||||
/**
|
||||
* 未授权
|
||||
* 状态码:403
|
||||
*/
|
||||
UNAUTHORIZED(403, "未授权"),
|
||||
|
||||
/**
|
||||
* Token 已过期
|
||||
* 状态码:403
|
||||
*/
|
||||
TOKEN_EXPIRE(403, "Token 已过期"),
|
||||
|
||||
/**
|
||||
* Token 不能为空
|
||||
* 状态码:403
|
||||
*/
|
||||
TOKEN_EMPTY(403, "Token 不能为空"),
|
||||
|
||||
/**
|
||||
* Token 非法
|
||||
* 状态码:403
|
||||
*/
|
||||
TOKEN_INVALID(403, "Token 非法"),
|
||||
|
||||
/**
|
||||
* 数据不存在
|
||||
* 状态码:404
|
||||
*/
|
||||
DATA_NOT_FOUND(404, "数据不存在"),
|
||||
|
||||
/**
|
||||
* 数据已存在
|
||||
* 状态码:409
|
||||
*/
|
||||
DATA_ALREADY_FOUND(409, "数据已存在"),
|
||||
|
||||
/**
|
||||
* 不支持该请求方法
|
||||
* 状态码:405
|
||||
*/
|
||||
METHOD_NOT_SUPPORT(405, "不支持该请求方法"),
|
||||
|
||||
/**
|
||||
* 系统错误
|
||||
* 状态码:500
|
||||
*/
|
||||
SYSTEM_ERROR(500, "系统错误,请联系管理员"),
|
||||
|
||||
// ==================== 认证相关 (1000 - 1099) ====================
|
||||
|
||||
/**
|
||||
* 用户名或密码错误
|
||||
* 状态码:1001
|
||||
*/
|
||||
USERNAME_OR_PASSWORD_ERROR(1001, "用户名或密码错误"),
|
||||
|
||||
/**
|
||||
* 参数错误
|
||||
* 状态码:1002
|
||||
*/
|
||||
PARAM_ERROR(1002, "参数错误"),
|
||||
|
||||
// ==================== 用户相关 (1100 - 1199) ====================
|
||||
|
||||
/**
|
||||
* 用户不存在
|
||||
* 状态码:1100
|
||||
*/
|
||||
USER_NOT_FOUND(1100, "用户不存在"),
|
||||
|
||||
/**
|
||||
* 密码错误
|
||||
* 状态码:1101
|
||||
*/
|
||||
PASSWORD_ERROR(1101, "密码错误"),
|
||||
|
||||
/**
|
||||
* 账号已被锁定
|
||||
* 状态码:1102
|
||||
*/
|
||||
USER_LOCKED(1102, "账号已被锁定"),
|
||||
|
||||
/**
|
||||
* 账号已被禁用
|
||||
* 状态码:1103
|
||||
*/
|
||||
USER_DISABLED(1103, "账号已被禁用"),
|
||||
|
||||
/**
|
||||
* 用户已存在
|
||||
* 状态码:1104
|
||||
*/
|
||||
USER_ALREADY_EXISTS(1104, "用户已存在"),
|
||||
|
||||
// ==================== 角色相关 (1200 - 1299) ====================
|
||||
|
||||
/**
|
||||
* 角色不存在
|
||||
* 状态码:1200
|
||||
*/
|
||||
ROLE_NOT_FOUND(1200, "角色不存在"),
|
||||
|
||||
/**
|
||||
* 角色已存在
|
||||
* 状态码:1201
|
||||
*/
|
||||
ROLE_ALREADY_EXISTS(1201, "角色已存在"),
|
||||
|
||||
/**
|
||||
* 角色正在使用中,无法删除
|
||||
* 状态码:1202
|
||||
*/
|
||||
ROLE_IN_USE(1202, "角色正在使用中,无法删除"),
|
||||
|
||||
// ==================== 权限相关 (1300 - 1399) ====================
|
||||
|
||||
/**
|
||||
* 权限不存在
|
||||
* 状态码:1300
|
||||
*/
|
||||
PERMISSION_NOT_FOUND(1300, "权限不存在"),
|
||||
|
||||
/**
|
||||
* 权限已存在
|
||||
* 状态码:1301
|
||||
*/
|
||||
PERMISSION_ALREADY_EXISTS(1301, "权限已存在"),
|
||||
|
||||
// ==================== 菜单相关 (1400 - 1499) ====================
|
||||
|
||||
/**
|
||||
* 菜单不存在
|
||||
* 状态码:1400
|
||||
*/
|
||||
MENU_NOT_FOUND(1400, "菜单不存在"),
|
||||
|
||||
/**
|
||||
* 菜单存在子菜单,无法删除
|
||||
* 状态码:1401
|
||||
*/
|
||||
MENU_HAS_CHILDREN(1401, "菜单存在子菜单,无法删除"),
|
||||
|
||||
/**
|
||||
* 菜单正在使用中
|
||||
* 状态码:1402
|
||||
*/
|
||||
MENU_IN_USE(1402, "菜单正在使用中");
|
||||
|
||||
/**
|
||||
* 状态码
|
||||
*/
|
||||
private final int code;
|
||||
|
||||
/**
|
||||
* 消息
|
||||
*/
|
||||
private final String message;
|
||||
private final Map<String, Object> json; // 预先创建的不可变 Map
|
||||
|
||||
/**
|
||||
* JSON 格式的数据(用于序列化)
|
||||
*/
|
||||
private final Map<String, Object> json;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param code 状态码
|
||||
* @param message 消息
|
||||
*/
|
||||
RestCode(Integer code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
@@ -36,9 +224,16 @@ public enum RestCode {
|
||||
this.json = map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为 JSON
|
||||
* 说明:
|
||||
* - 当使用 @JsonValue 注解时,
|
||||
* - Jackson 会调用此方法获取序列化的值
|
||||
*
|
||||
* @return JSON 格式的数据
|
||||
*/
|
||||
@JsonValue
|
||||
public Map<String,Object> toJson(){
|
||||
public Map<String, Object> toJson() {
|
||||
return json;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
114
src/main/java/com/aisi/template/domain/dto/MenuDto.java
Normal file
114
src/main/java/com/aisi/template/domain/dto/MenuDto.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 菜单数据传输对象
|
||||
* 用于创建和更新菜单
|
||||
*
|
||||
* 验证规则:
|
||||
* - 父菜单ID:不能为空,默认 0(根菜单)
|
||||
* - 菜单名称:不能为空
|
||||
* - 菜单类型:不能为空(1=目录,2=菜单,3=按钮)
|
||||
* - 排序字段:不能为空,默认 0
|
||||
* - 可见性:不能为空,默认 1(可见)
|
||||
* - 状态:不能为空,默认 1(启用)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
public class MenuDto {
|
||||
|
||||
/**
|
||||
* 菜单ID
|
||||
* - 更新时需要提供
|
||||
* - 创建时不需要提供
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 父菜单ID
|
||||
* - 不能为空
|
||||
* - 0 或 null:根菜单
|
||||
* - 其他值:子菜单
|
||||
* - 默认值:0
|
||||
*/
|
||||
@NotNull(message = "父菜单ID不能为空")
|
||||
private Long parentId = 0L;
|
||||
|
||||
/**
|
||||
* 菜单名称
|
||||
* - 不能为空
|
||||
* - 示例:用户管理、角色管理
|
||||
*/
|
||||
@NotBlank(message = "菜单名称不能为空")
|
||||
private String menuName;
|
||||
|
||||
/**
|
||||
* 菜单类型
|
||||
* - 不能为空
|
||||
* - 1:目录(DIRECTORY)
|
||||
* - 2:菜单(MENU)
|
||||
* - 3:按钮(BUTTON)
|
||||
*/
|
||||
@NotNull(message = "菜单类型不能为空")
|
||||
private Integer menuType;
|
||||
|
||||
/**
|
||||
* 菜单路径
|
||||
* - 用于前端路由
|
||||
* - 示例:/user, /role
|
||||
*/
|
||||
private String menuPath;
|
||||
|
||||
/**
|
||||
* 组件路径
|
||||
* - 前端组件文件路径
|
||||
* - 示例:views/user/List.vue
|
||||
*/
|
||||
private String component;
|
||||
|
||||
/**
|
||||
* 菜单图标
|
||||
* - 图标名称
|
||||
* - 示例:user, role, setting
|
||||
*/
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* - 不能为空
|
||||
* - 数值越小越靠前
|
||||
* - 默认值:0
|
||||
*/
|
||||
@NotNull(message = "排序字段不能为空")
|
||||
private Integer sortOrder = 0;
|
||||
|
||||
/**
|
||||
* 可见性
|
||||
* - 不能为空
|
||||
* - 1:可见(默认)
|
||||
* - 0:不可见
|
||||
*/
|
||||
@NotNull(message = "可见性不能为空")
|
||||
private Integer visible = 1;
|
||||
|
||||
/**
|
||||
* 菜单状态
|
||||
* - 不能为空
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@NotNull(message = "状态不能为空")
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* - 用于控制菜单的访问权限
|
||||
* - 示例:user:read, role:read
|
||||
*/
|
||||
private String permissionCode;
|
||||
}
|
||||
@@ -1,28 +1,66 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import com.aisi.template.annotation.StrongPassword;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 密码重置确认数据传输对象
|
||||
* 用于使用验证码重置密码
|
||||
*
|
||||
* 验证规则:
|
||||
* - 邮箱:不能为空,必须符合邮箱格式
|
||||
* - 验证码:必须是 6 位数字
|
||||
* - 新密码:不能为空,需满足强密码要求
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户收到验证码后,输入验证码和新密码重置密码
|
||||
*
|
||||
* 注意:
|
||||
* - 验证码 10 分钟有效
|
||||
* - 验证码最多尝试 5 次,超过后失效
|
||||
* - 验证码使用后立即失效
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "重置密码确认请求")
|
||||
public class PasswordResetConfirmDto {
|
||||
|
||||
/**
|
||||
* 邮箱地址
|
||||
* - 不能为空
|
||||
* - 必须符合邮箱格式
|
||||
* - 示例:user@example.com
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Schema(description = "邮箱")
|
||||
@Schema(description = "邮箱", example = "user@example.com")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 验证码
|
||||
* - 不能为空
|
||||
* - 必须是 6 位数字
|
||||
* - 示例:123456
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字")
|
||||
@Schema(description = "6位验证码")
|
||||
@Schema(description = "6位验证码", example = "123456")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 新密码
|
||||
* - 不能为空
|
||||
* - 需满足强密码要求:包含大小写字母、数字、特殊字符
|
||||
* - 示例:NewPassword123!
|
||||
*/
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间")
|
||||
@Schema(description = "新密码")
|
||||
@StrongPassword
|
||||
@Schema(description = "新密码", example = "NewPassword123!")
|
||||
private String newPassword;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,36 @@ import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 密码重置请求数据传输对象
|
||||
* 用于请求发送密码重置验证码
|
||||
*
|
||||
* 验证规则:
|
||||
* - 邮箱:不能为空,必须符合邮箱格式
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户忘记密码时,输入邮箱请求重置验证码
|
||||
*
|
||||
* 注意:
|
||||
* - 为防止用户名枚举,无论邮箱是否存在都返回相同消息
|
||||
* - 同一邮箱 60 秒内只能请求一次
|
||||
* - 同一邮箱 1 小时内最多请求 5 次
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "找回密码请求")
|
||||
public class PasswordResetRequestDto {
|
||||
|
||||
/**
|
||||
* 邮箱地址
|
||||
* - 不能为空
|
||||
* - 必须符合邮箱格式
|
||||
* - 示例:user@example.com
|
||||
*/
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Schema(description = "邮箱")
|
||||
@Schema(description = "邮箱", example = "user@example.com")
|
||||
private String email;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "刷新令牌请求")
|
||||
public class RefreshTokenDto {
|
||||
|
||||
@NotBlank(message = "刷新令牌不能为空")
|
||||
@Schema(description = "刷新令牌")
|
||||
private String refreshToken;
|
||||
}
|
||||
65
src/main/java/com/aisi/template/domain/dto/RoleDto.java
Normal file
65
src/main/java/com/aisi/template/domain/dto/RoleDto.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色数据传输对象
|
||||
* 用于创建和更新角色
|
||||
*
|
||||
* 验证规则:
|
||||
* - 角色编码:不能为空,格式如 ROLE_USER
|
||||
* - 角色名称:不能为空
|
||||
* - 排序字段:不能为空,默认 0
|
||||
* - 状态:不能为空,默认 1(启用)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
public class RoleDto {
|
||||
|
||||
/**
|
||||
* 角色编码
|
||||
* - 不能为空
|
||||
* - 格式:ROLE_ + 名称,如 ROLE_USER, ROLE_ADMIN
|
||||
* - 示例:ROLE_USER
|
||||
*/
|
||||
@NotBlank(message = "角色编码不能为空")
|
||||
private String roleCode;
|
||||
|
||||
/**
|
||||
* 角色名称
|
||||
* - 不能为空
|
||||
* - 用于界面展示
|
||||
* - 示例:普通用户、管理员
|
||||
*/
|
||||
@NotBlank(message = "角色名称不能为空")
|
||||
private String roleName;
|
||||
|
||||
/**
|
||||
* 角色描述
|
||||
* - 可选
|
||||
* - 描述角色的用途和权限范围
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* - 不能为空
|
||||
* - 数值越小越靠前
|
||||
* - 默认值:0
|
||||
*/
|
||||
@NotNull(message = "排序字段不能为空")
|
||||
private Integer sortOrder = 0;
|
||||
|
||||
/**
|
||||
* 角色状态
|
||||
* - 不能为空
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@NotNull(message = "状态不能为空")
|
||||
private Integer status = 1;
|
||||
}
|
||||
41
src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java
Normal file
41
src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色查询参数数据传输对象
|
||||
* 用于查询角色列表
|
||||
*
|
||||
* 查询条件:
|
||||
* - 角色编码:模糊匹配
|
||||
* - 角色名称:模糊匹配
|
||||
* - 状态:精确匹配
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
public class RoleQueryDto {
|
||||
|
||||
/**
|
||||
* 角色编码
|
||||
* - 模糊匹配
|
||||
* - 示例:ROLE_USER 会匹配 role_code LIKE '%ROLE_USER%'
|
||||
*/
|
||||
private String roleCode;
|
||||
|
||||
/**
|
||||
* 角色名称
|
||||
* - 模糊匹配
|
||||
* - 示例:管理员 会匹配 role_name LIKE '%管理员%'
|
||||
*/
|
||||
private String roleName;
|
||||
|
||||
/**
|
||||
* 角色状态
|
||||
* - 精确匹配
|
||||
* - 1:启用
|
||||
* - 0:禁用
|
||||
*/
|
||||
private Integer status;
|
||||
}
|
||||
@@ -1,24 +1,59 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import com.aisi.template.annotation.StrongPassword;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户数据传输对象
|
||||
* 用于用户注册和登录请求
|
||||
*
|
||||
* 验证规则:
|
||||
* - 用户名:2-50 位字符,不能为空
|
||||
* - 密码:不能为空,需满足强密码要求(@StrongPassword)
|
||||
* - 邮箱:可选,注册时建议提供
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户注册:需要用户名、密码、邮箱
|
||||
* - 用户登录:需要用户名、密码
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户注册/登录请求")
|
||||
public class UserDto {
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
* - 长度:2-50 位字符
|
||||
* - 不能为空
|
||||
* - 示例:admin, user01
|
||||
*/
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间")
|
||||
@Schema(description = "用户名", example = "admin")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 密码
|
||||
* - 不能为空
|
||||
* - 需满足强密码要求:包含大小写字母、数字、特殊字符
|
||||
* - 示例:Password123!
|
||||
*/
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间")
|
||||
@Schema(description = "密码", example = "123456")
|
||||
@StrongPassword
|
||||
@Schema(description = "密码", example = "Password123!")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 邮箱地址
|
||||
* - 可选字段
|
||||
* - 注册时建议提供,用于找回密码
|
||||
* - 示例:user@example.com
|
||||
*/
|
||||
@Schema(description = "邮箱(注册时可选)", example = "user@example.com")
|
||||
private String email;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,52 @@ package com.aisi.template.domain.dto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户查询参数数据传输对象
|
||||
* 用于分页查询用户列表
|
||||
*
|
||||
* 查询条件:
|
||||
* - 分页参数:页码、每页大小
|
||||
* - 关键词:匹配用户名或邮箱(模糊搜索)
|
||||
* - 状态:精确匹配(1=正常,0=禁用)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户查询参数")
|
||||
public class UserQueryDto {
|
||||
|
||||
/**
|
||||
* 页码
|
||||
* - 默认值:1
|
||||
* - 从 1 开始计数
|
||||
*/
|
||||
@Schema(description = "页码", example = "1")
|
||||
private Integer page = 1;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
* - 默认值:10
|
||||
* - 最小值:1
|
||||
*/
|
||||
@Schema(description = "每页大小", example = "10")
|
||||
private Integer size = 10;
|
||||
|
||||
@Schema(description = "关键词(用户名/邮箱)")
|
||||
/**
|
||||
* 关键词
|
||||
* - 模糊匹配用户名或邮箱
|
||||
* - 示例:admin 会匹配 username LIKE '%admin%' OR email LIKE '%admin%'
|
||||
*/
|
||||
@Schema(description = "关键词(用户名/邮箱)", example = "admin")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "角色(USER/ADMIN)")
|
||||
private String role;
|
||||
|
||||
@Schema(description = "状态(1=正常 0=禁用)")
|
||||
/**
|
||||
* 用户状态
|
||||
* - 1:正常(启用)
|
||||
* - 0:禁用
|
||||
* - 精确匹配
|
||||
*/
|
||||
@Schema(description = "状态(1=正常 0=禁用)", example = "1")
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
package com.aisi.template.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户角色更新数据传输对象
|
||||
* 用于为用户分配或修改角色
|
||||
*
|
||||
* 验证规则:
|
||||
* - 角色ID列表:不能为空
|
||||
*
|
||||
* 使用场景:
|
||||
* - 管理员为用户分配角色
|
||||
* - 管理员修改用户的角色
|
||||
*
|
||||
* 注意:
|
||||
* - 不能修改当前登录用户的角色
|
||||
* - 角色变更后用户需要重新登录才能生效
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户角色更新请求")
|
||||
public class UserRoleUpdateDto {
|
||||
|
||||
@NotBlank(message = "角色不能为空")
|
||||
@Schema(description = "角色(USER/ADMIN)", example = "ADMIN")
|
||||
private String role;
|
||||
/**
|
||||
* 角色ID列表
|
||||
* - 不能为空
|
||||
* - 会完全替换用户的角色列表
|
||||
* - 示例:[1, 2] 表示分配角色ID为1和2的角色
|
||||
*/
|
||||
@NotEmpty(message = "角色不能为空")
|
||||
@Schema(description = "角色ID列表", example = "[1, 2]")
|
||||
private Set<Long> roleIds;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,34 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户状态更新数据传输对象
|
||||
* 用于更新用户的启用/禁用状态
|
||||
*
|
||||
* 验证规则:
|
||||
* - 状态:不能为空,只能是 0 或 1
|
||||
*
|
||||
* 使用场景:
|
||||
* - 管理员启用或禁用用户账户
|
||||
*
|
||||
* 注意:
|
||||
* - 不能禁用当前登录的用户
|
||||
* - 状态变更后用户需要重新登录
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户状态更新请求")
|
||||
public class UserStatusUpdateDto {
|
||||
|
||||
/**
|
||||
* 用户状态
|
||||
* - 不能为空
|
||||
* - 只能是 0 或 1
|
||||
* - 1:正常(启用)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@NotNull(message = "状态不能为空")
|
||||
@Min(value = 0, message = "状态只能为 0 或 1")
|
||||
@Max(value = 1, message = "状态只能为 0 或 1")
|
||||
|
||||
@@ -6,6 +6,26 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 密码重置验证码实体类
|
||||
* 存储密码重置验证码信息
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 邮箱:用户邮箱
|
||||
* 2. 验证码:验证码的哈希值(不存储明文)
|
||||
* 3. 有效期:过期时间
|
||||
* 4. 使用状态:是否已使用
|
||||
* 5. 尝试次数:验证失败次数
|
||||
*
|
||||
* 安全机制:
|
||||
* - 验证码使用 SHA-256 哈希存储
|
||||
* - 验证码默认 10 分钟有效
|
||||
* - 最多尝试 5 次,超过后失效
|
||||
* - 同一邮箱 60 秒内只能请求一次
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "password_reset_codes", indexes = {
|
||||
@Index(name = "idx_password_reset_email", columnList = "email"),
|
||||
@@ -14,30 +34,61 @@ import java.time.LocalDateTime;
|
||||
@Data
|
||||
public class PasswordResetCode {
|
||||
|
||||
/**
|
||||
* 验证码ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 邮箱地址(不可为空)
|
||||
*/
|
||||
@Column(nullable = false, length = 255)
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 验证码哈希值(不可为空)
|
||||
* - 存储 SHA-256 哈希值,不存储明文验证码
|
||||
* - 防止数据库泄露导致验证码泄露
|
||||
*/
|
||||
@Column(name = "code_hash", nullable = false, length = 64)
|
||||
private String codeHash;
|
||||
|
||||
/**
|
||||
* 过期时间(不可为空)
|
||||
* 默认:当前时间 + 10 分钟
|
||||
*/
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
/**
|
||||
* 是否已使用(不可为空)
|
||||
* - false:未使用(默认)
|
||||
* - true:已使用
|
||||
*/
|
||||
@Column(name = "used", nullable = false)
|
||||
private Boolean used = false;
|
||||
|
||||
/**
|
||||
* 尝试次数(不可为空)
|
||||
* - 验证失败时递增
|
||||
* - 超过 5 次后验证码失效
|
||||
*/
|
||||
@Column(name = "attempt_count", nullable = false)
|
||||
private Integer attemptCount = 0;
|
||||
|
||||
/**
|
||||
* 创建时间(不可为空,不可更新)
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
|
||||
126
src/main/java/com/aisi/template/domain/entity/RefreshToken.java
Normal file
126
src/main/java/com/aisi/template/domain/entity/RefreshToken.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Refresh Token 实体类
|
||||
* 定义刷新令牌的存储和管理
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 用户关联:用户ID
|
||||
* 2. Token 信息:Token 哈希值
|
||||
* 3. 设备信息:设备信息、IP地址
|
||||
* 4. 有效期:过期时间、撤销状态
|
||||
*
|
||||
* 安全机制:
|
||||
* - 数据库只存储 Token 的 SHA-256 哈希值,不存储原始值
|
||||
* - 支持 Token 轮换(每次刷新生成新 Token)
|
||||
* - 支持 Token 撤销(登出、修改密码)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "refresh_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RefreshToken {
|
||||
|
||||
/**
|
||||
* Token ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 用户ID(不可为空)
|
||||
*/
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* Token 哈希值(唯一,不可为空)
|
||||
* - 存储 SHA-256 哈希值,不存储原始 Token
|
||||
* - 防止数据库泄露导致 Token 泄露
|
||||
*/
|
||||
@Column(name = "token_hash", nullable = false, unique = true, length = 128)
|
||||
private String tokenHash;
|
||||
|
||||
/**
|
||||
* 设备信息
|
||||
* 如:iPhone 14 Pro, Windows PC
|
||||
* 用于向用户展示登录设备列表
|
||||
*/
|
||||
@Column(name = "device_info", length = 255)
|
||||
private String deviceInfo;
|
||||
|
||||
/**
|
||||
* IP 地址
|
||||
* 支持 IPv4 和 IPv6
|
||||
* 用于安全审计
|
||||
*/
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
/**
|
||||
* 过期时间(不可为空)
|
||||
* 默认:当前时间 + 7 天
|
||||
*/
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
/**
|
||||
* 是否已撤销
|
||||
* - false:有效(默认)
|
||||
* - true:已撤销
|
||||
*/
|
||||
@Column(name = "revoked", nullable = false)
|
||||
private Boolean revoked = false;
|
||||
|
||||
/**
|
||||
* 撤销时间
|
||||
* 记录 Token 被撤销的时间
|
||||
*/
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
/**
|
||||
* 创建时间(不可为空,不可更新)
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否过期
|
||||
*
|
||||
* @return true 表示 Token 已过期
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否有效
|
||||
* 有效 = 未过期 且 未撤销
|
||||
*
|
||||
* @return true 表示 Token 有效
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return !revoked && !isExpired();
|
||||
}
|
||||
}
|
||||
150
src/main/java/com/aisi/template/domain/entity/SysAuditLog.java
Normal file
150
src/main/java/com/aisi/template/domain/entity/SysAuditLog.java
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 系统审计日志实体类
|
||||
* 记录用户操作和系统行为
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 操作用户:用户ID、用户名
|
||||
* 2. 操作信息:操作类型、资源、资源ID
|
||||
* 3. 请求信息:请求方法、请求URI、IP地址、User-Agent
|
||||
* 4. 操作结果:状态、错误信息
|
||||
*
|
||||
* 操作类型(action):
|
||||
* - LOGIN:登录
|
||||
* - LOGOUT:登出
|
||||
* - CREATE:创建
|
||||
* - UPDATE:更新
|
||||
* - DELETE:删除
|
||||
*
|
||||
* 资源类型(resource):
|
||||
* - user:用户
|
||||
* - role:角色
|
||||
* - menu:菜单
|
||||
* - permission:权限
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sys_audit_log")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SysAuditLog {
|
||||
|
||||
/**
|
||||
* 日志ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 操作用户ID
|
||||
*/
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 操作用户名
|
||||
*/
|
||||
@Column(name = "username", length = 50)
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 操作类型(不可为空)
|
||||
* - LOGIN:登录
|
||||
* - LOGOUT:登出
|
||||
* - CREATE:创建
|
||||
* - UPDATE:更新
|
||||
* - DELETE:删除
|
||||
*/
|
||||
@Column(name = "action", nullable = false, length = 50)
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 操作资源(不可为空)
|
||||
* 如:user, role, menu, permission
|
||||
*/
|
||||
@Column(name = "resource", nullable = false, length = 100)
|
||||
private String resource;
|
||||
|
||||
/**
|
||||
* 资源ID
|
||||
* 被操作资源的ID
|
||||
*/
|
||||
@Column(name = "resource_id", length = 50)
|
||||
private String resourceId;
|
||||
|
||||
/**
|
||||
* 操作描述
|
||||
* 人类可读的操作描述
|
||||
*/
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 请求方法
|
||||
* - GET, POST, PUT, DELETE 等
|
||||
*/
|
||||
@Column(name = "request_method", length = 10)
|
||||
private String requestMethod;
|
||||
|
||||
/**
|
||||
* 请求URI
|
||||
* 请求的完整路径
|
||||
*/
|
||||
@Column(name = "request_uri", length = 500)
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* IP 地址
|
||||
* 操作者的 IP 地址,支持 IPv4 和 IPv6
|
||||
*/
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
/**
|
||||
* User-Agent
|
||||
* 浏览器或客户端信息
|
||||
*/
|
||||
@Column(name = "user_agent", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 操作状态(不可为空)
|
||||
* - 1:成功(默认)
|
||||
* - 0:失败
|
||||
*/
|
||||
@Column(name = "status", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
* 操作失败时的错误描述
|
||||
*/
|
||||
@Column(name = "error_message", length = 1000)
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 创建时间(不可为空,不可更新)
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
172
src/main/java/com/aisi/template/domain/entity/SysMenu.java
Normal file
172
src/main/java/com/aisi/template/domain/entity/SysMenu.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 系统菜单实体类
|
||||
* 定义系统的菜单结构和导航
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:菜单名称、菜单类型
|
||||
* 2. 树形结构:父菜单ID
|
||||
* 3. 菜单配置:路径、组件、图标、排序
|
||||
* 4. 显示控制:可见性、启用状态
|
||||
* 5. 权限控制:关联的权限编码
|
||||
* 6. 角色关系:多对多关联角色
|
||||
*
|
||||
* 菜单类型:
|
||||
* - DIRECTORY(1):目录,仅用于分组,不对应具体页面
|
||||
* - MENU(2):菜单项,对应具体页面
|
||||
* - BUTTON(3):按钮,页面内的操作按钮
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sys_menu")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
public class SysMenu {
|
||||
|
||||
/**
|
||||
* 菜单ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@EqualsAndHashCode.Include
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 父菜单ID
|
||||
* - 0 或 null:根菜单
|
||||
* - 其他值:子菜单
|
||||
*/
|
||||
@Column(name = "parent_id", nullable = false)
|
||||
private Long parentId = 0L;
|
||||
|
||||
/**
|
||||
* 菜单名称(不可为空)
|
||||
*/
|
||||
@Column(name = "menu_name", nullable = false, length = 50)
|
||||
private String menuName;
|
||||
|
||||
/**
|
||||
* 菜单类型(不可为空)
|
||||
* - 1:目录(DIRECTORY)
|
||||
* - 2:菜单(MENU)
|
||||
* - 3:按钮(BUTTON)
|
||||
*/
|
||||
@Column(name = "menu_type", nullable = false)
|
||||
private Integer menuType;
|
||||
|
||||
/**
|
||||
* 菜单路径
|
||||
* 用于前端路由,如:/user, /role
|
||||
*/
|
||||
@Column(name = "menu_path", length = 200)
|
||||
private String menuPath;
|
||||
|
||||
/**
|
||||
* 组件路径
|
||||
* 前端组件文件路径,如:views/user/List.vue
|
||||
*/
|
||||
@Column(name = "component", length = 200)
|
||||
private String component;
|
||||
|
||||
/**
|
||||
* 菜单图标
|
||||
* 图标名称,如:user, role, setting
|
||||
*/
|
||||
@Column(name = "icon", length = 100)
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* 数值越小越靠前
|
||||
*/
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder = 0;
|
||||
|
||||
/**
|
||||
* 可见性
|
||||
* - 1:可见(默认)
|
||||
* - 0:不可见
|
||||
*/
|
||||
@Column(name = "visible", nullable = false)
|
||||
private Integer visible = 1;
|
||||
|
||||
/**
|
||||
* 菜单状态
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* 用于控制菜单的访问权限
|
||||
*/
|
||||
@Column(name = "permission_code", length = 100)
|
||||
private String permissionCode;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 拥有此菜单的角色列表(多对多关系的反向维护)
|
||||
* - mappedBy = "menus":由 SysRole 的 menus 字段维护关系
|
||||
* - 懒加载:避免查询菜单时加载所有角色
|
||||
*/
|
||||
@ManyToMany(mappedBy = "menus", fetch = FetchType.LAZY)
|
||||
@ToString.Exclude
|
||||
private Set<SysRole> roles = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 子菜单列表(非持久化字段)
|
||||
* - @Transient:不映射到数据库
|
||||
* - 用于构建菜单树形结构
|
||||
*/
|
||||
@Transient
|
||||
@ToString.Exclude
|
||||
private List<SysMenu> children = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前自动设置时间
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
133
src/main/java/com/aisi/template/domain/entity/SysPermission.java
Normal file
133
src/main/java/com/aisi/template/domain/entity/SysPermission.java
Normal file
@@ -0,0 +1,133 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 系统权限实体类
|
||||
* 定义系统的资源操作权限
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:权限编码、权限名称
|
||||
* 2. 资源操作:资源名称、操作类型
|
||||
* 3. 描述和状态:描述信息、启用状态
|
||||
* 4. 角色关系:多对多关联角色
|
||||
*
|
||||
* 权限格式:
|
||||
* - 资源:操作,如 user:read, user:write
|
||||
* - 资源:系统中的实体,如 user, role, permission, menu
|
||||
* - 操作:read(读取)、write(写入)、delete(删除)
|
||||
*
|
||||
* 设计说明:
|
||||
* - 权限是系统预定义的,通过数据库迁移脚本初始化
|
||||
* - 权限通过角色分配给用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sys_permission")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
public class SysPermission {
|
||||
|
||||
/**
|
||||
* 权限ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@EqualsAndHashCode.Include
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 权限编码(唯一,不可为空)
|
||||
* 格式:资源:操作,如 user:read, role:write
|
||||
*/
|
||||
@Column(name = "permission_code", nullable = false, unique = true, length = 100)
|
||||
private String permissionCode;
|
||||
|
||||
/**
|
||||
* 权限名称(不可为空)
|
||||
* 用于界面展示,如:查看用户、编辑用户
|
||||
*/
|
||||
@Column(name = "permission_name", nullable = false, length = 100)
|
||||
private String permissionName;
|
||||
|
||||
/**
|
||||
* 资源名称(不可为空)
|
||||
* 如:user(用户)、role(角色)、menu(菜单)
|
||||
*/
|
||||
@Column(name = "resource", nullable = false, length = 50)
|
||||
private String resource;
|
||||
|
||||
/**
|
||||
* 操作类型(不可为空)
|
||||
* - read:读取(查看)
|
||||
* - write:写入(创建/编辑)
|
||||
* - delete:删除
|
||||
*/
|
||||
@Column(name = "action", nullable = false, length = 50)
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 权限描述
|
||||
*/
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 权限状态
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 拥有此权限的角色列表(多对多关系的反向维护)
|
||||
* - mappedBy = "permissions":由 SysRole 的 permissions 字段维护关系
|
||||
* - 懒加载:避免查询权限时加载所有角色
|
||||
*/
|
||||
@ManyToMany(mappedBy = "permissions", fetch = FetchType.LAZY)
|
||||
@ToString.Exclude
|
||||
private Set<SysRole> roles = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前自动设置时间
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
139
src/main/java/com/aisi/template/domain/entity/SysRole.java
Normal file
139
src/main/java/com/aisi/template/domain/entity/SysRole.java
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 系统角色实体类
|
||||
* 定义角色的基本信息和权限/菜单关系
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:角色编码、角色名称、描述
|
||||
* 2. 排序和状态:排序字段、启用状态
|
||||
* 3. 权限关系:多对多关联权限
|
||||
* 4. 菜单关系:多对多关联菜单
|
||||
*
|
||||
* 角色说明:
|
||||
* - 角色是权限和菜单的集合
|
||||
* - 用户通过角色获得权限和菜单访问权限
|
||||
* - 预定义角色:SUPER_ADMIN(超级管理员)、ADMIN(管理员)、USER(普通用户)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sys_role")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
public class SysRole {
|
||||
|
||||
/**
|
||||
* 角色ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@EqualsAndHashCode.Include
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 角色编码(唯一,不可为空)
|
||||
* 格式:ROLE_ + 名称,如:ROLE_USER, ROLE_ADMIN
|
||||
*/
|
||||
@Column(name = "role_code", nullable = false, unique = true, length = 50)
|
||||
private String roleCode;
|
||||
|
||||
/**
|
||||
* 角色名称(不可为空)
|
||||
* 用于界面展示,如:普通用户、管理员
|
||||
*/
|
||||
@Column(name = "role_name", nullable = false, length = 100)
|
||||
private String roleName;
|
||||
|
||||
/**
|
||||
* 角色描述
|
||||
*/
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* 数值越小越靠前
|
||||
*/
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder = 0;
|
||||
|
||||
/**
|
||||
* 角色状态
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 角色拥有的权限列表(多对多关系)
|
||||
* - 懒加载:避免查询角色时加载所有权限
|
||||
* - JoinTable:关联表 sys_role_permission
|
||||
*/
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "sys_role_permission",
|
||||
joinColumns = @JoinColumn(name = "role_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "permission_id")
|
||||
)
|
||||
@ToString.Exclude
|
||||
private Set<SysPermission> permissions = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 角色拥有的菜单列表(多对多关系)
|
||||
* - 懒加载:避免查询角色时加载所有菜单
|
||||
* - JoinTable:关联表 sys_role_menu
|
||||
*/
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "sys_role_menu",
|
||||
joinColumns = @JoinColumn(name = "role_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "menu_id")
|
||||
)
|
||||
@ToString.Exclude
|
||||
private Set<SysMenu> menus = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 创建前自动设置时间
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前自动设置时间
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,129 @@
|
||||
package com.aisi.template.domain.entity;
|
||||
|
||||
import com.aisi.template.domain.enums.Role;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户实体类
|
||||
* 定义用户的基本信息和账户状态
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:用户名、密码、邮箱
|
||||
* 2. 账户状态:启用/禁用、锁定状态
|
||||
* 3. 安全信息:失败次数、锁定时间、密码修改时间
|
||||
* 4. 角色关系:多对多关联角色
|
||||
*
|
||||
* 安全机制:
|
||||
* - 密码使用 BCrypt 加密存储
|
||||
* - 连续失败 5 次锁定账户 30 分钟
|
||||
* - 支持永久锁定(禁用账户)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class User {
|
||||
|
||||
/**
|
||||
* 用户ID(主键)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@EqualsAndHashCode.Include
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 用户名(唯一,不可为空)
|
||||
*/
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 密码(BCrypt 加密,不可为空)
|
||||
* 使用 @JsonIgnore 防止序列化时泄露密码
|
||||
* 使用 @ToString.Exclude 防止 toString 时泄露密码
|
||||
*/
|
||||
@ToString.Exclude
|
||||
@JsonIgnore
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 邮箱地址(唯一)
|
||||
*/
|
||||
@Column(unique = true)
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 用户状态
|
||||
* - 1:启用(默认)
|
||||
* - 0:禁用(永久锁定)
|
||||
*/
|
||||
@Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'")
|
||||
private Integer status = 1;
|
||||
|
||||
@Column(name = "role", nullable = false, length = 20, columnDefinition = "VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色:USER=普通用户,ADMIN=管理员'")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Role role = Role.USER;
|
||||
/**
|
||||
* 登录失败次数
|
||||
* 连续失败 5 次将锁定账户
|
||||
*/
|
||||
@Column(name = "failed_login_count", nullable = false)
|
||||
private Integer failedLoginCount = 0;
|
||||
|
||||
/**
|
||||
* 账户锁定到期时间
|
||||
* - null:未锁定
|
||||
* - 有值:锁定到此时间
|
||||
*/
|
||||
@Column(name = "locked_until")
|
||||
private LocalDateTime lockedUntil;
|
||||
|
||||
/**
|
||||
* 密码最后修改时间
|
||||
* 用于密码过期策略
|
||||
*/
|
||||
@Column(name = "password_changed_at")
|
||||
private LocalDateTime passwordChangedAt;
|
||||
|
||||
/**
|
||||
* 用户角色列表(多对多关系)
|
||||
* - 懒加载:避免查询用户时加载所有角色
|
||||
* - JoinTable:关联表 sys_user_role
|
||||
*/
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "sys_user_role",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
@ToString.Exclude
|
||||
private Set<SysRole> roles = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 创建时间(自动填充)
|
||||
*/
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间(自动填充)
|
||||
*/
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
@@ -52,8 +131,38 @@ public class User {
|
||||
|
||||
/**
|
||||
* 检查用户是否启用
|
||||
*
|
||||
* @return true 表示账户启用
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return status != null && status == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否被锁定
|
||||
* 说明:
|
||||
* - 如果锁定时间为空,表示未锁定
|
||||
* - 如果当前时间在锁定时间之前,表示仍然锁定
|
||||
* - 锁定会自动过期(临时锁定)
|
||||
*
|
||||
* @return true 表示账户被锁定
|
||||
*/
|
||||
public boolean isLocked() {
|
||||
if (lockedUntil == null) {
|
||||
return false;
|
||||
}
|
||||
return LocalDateTime.now().isBefore(lockedUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否被永久锁定(禁用)
|
||||
* 说明:
|
||||
* - status = 0 表示账户被管理员禁用
|
||||
* - 不同于临时锁定,永久锁定需要管理员手动解除
|
||||
*
|
||||
* @return true 表示账户被永久锁定
|
||||
*/
|
||||
public boolean isPermanentlyLocked() {
|
||||
return status != null && status == 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.aisi.template.domain.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
// 通用错误
|
||||
PARAM_ERROR(400, "参数错误"),
|
||||
SYSTEM_ERROR(500, "系统繁忙,请稍后再试"),
|
||||
|
||||
// 用户相关 (1000 - 1999)
|
||||
USER_NOT_FOUND(1001, "用户不存在"),
|
||||
PASSWORD_ERROR(1002, "密码错误"),
|
||||
USER_LOCKED(1003, "账号已被锁定"),
|
||||
|
||||
// 业务相关 (2000 - 2999) - 预留给具体业务模块
|
||||
;
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.aisi.template.domain.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 用户角色枚举
|
||||
*/
|
||||
@Getter
|
||||
public enum Role {
|
||||
|
||||
/**
|
||||
* 普通用户
|
||||
*/
|
||||
USER("ROLE_USER", "普通用户"),
|
||||
|
||||
/**
|
||||
* 管理员
|
||||
*/
|
||||
ADMIN("ROLE_ADMIN", "管理员");
|
||||
|
||||
private final String authority;
|
||||
private final String description;
|
||||
|
||||
Role(String authority, String description) {
|
||||
this.authority = authority;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库字符串转换为枚举
|
||||
*/
|
||||
public static Role fromString(String role) {
|
||||
if (role == null) {
|
||||
return USER;
|
||||
}
|
||||
try {
|
||||
return Role.valueOf(role.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return USER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为数据库存储格式
|
||||
*/
|
||||
public String toDbValue() {
|
||||
return this.name();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.aisi.template.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 登录响应视图对象
|
||||
* 用于返回登录成功后的响应数据
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. Token 信息:访问令牌、刷新令牌、过期时间
|
||||
* 2. 用户信息:用户ID、用户名、邮箱
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登录成功后返回
|
||||
* - 用户注册成功后返回(自动登录)
|
||||
* - Token 刷新后返回
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "登录响应")
|
||||
public class LoginResponseVo {
|
||||
|
||||
/**
|
||||
* 访问令牌(Access Token)
|
||||
* - 用于 API 访问认证
|
||||
* - 放在请求头:Authorization: Bearer {accessToken}
|
||||
* - 默认有效期:1 小时
|
||||
*/
|
||||
@Schema(description = "访问令牌")
|
||||
private String accessToken;
|
||||
|
||||
/**
|
||||
* 刷新令牌(Refresh Token)
|
||||
* - 用于获取新的 Access Token
|
||||
* - 当 Access Token 过期时使用
|
||||
* - 默认有效期:7 天
|
||||
*/
|
||||
@Schema(description = "刷新令牌")
|
||||
private String refreshToken;
|
||||
|
||||
/**
|
||||
* 访问令牌过期时间(秒)
|
||||
* - 默认:3600 秒(1 小时)
|
||||
* - 前端可根据此时间提前刷新 Token
|
||||
*/
|
||||
@Schema(description = "访问令牌过期时间(秒)")
|
||||
private Long expiresIn;
|
||||
|
||||
/**
|
||||
* 用户信息
|
||||
* - 包含用户的基本信息
|
||||
* - 前端可显示用户名等信息
|
||||
*/
|
||||
@Schema(description = "用户信息")
|
||||
private UserInfoVo userInfo;
|
||||
|
||||
/**
|
||||
* 用户信息视图对象
|
||||
* 包含用户的基本信息
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "用户信息")
|
||||
public static class UserInfoVo {
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@Schema(description = "用户ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 邮箱地址
|
||||
*/
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
}
|
||||
}
|
||||
228
src/main/java/com/aisi/template/domain/vo/MenuVo.java
Normal file
228
src/main/java/com/aisi/template/domain/vo/MenuVo.java
Normal file
@@ -0,0 +1,228 @@
|
||||
package com.aisi.template.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单视图对象
|
||||
* 用于返回菜单信息给前端
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:菜单ID、父菜单ID、菜单名称、菜单类型
|
||||
* 2. 菜单配置:路径、组件、图标、排序
|
||||
* 3. 显示控制:可见性、启用状态
|
||||
* 4. 权限控制:权限编码
|
||||
* 5. 子菜单:子菜单列表(树形结构)
|
||||
* 6. 时间信息:创建时间、更新时间
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class MenuVo {
|
||||
|
||||
/**
|
||||
* 菜单ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 父菜单ID
|
||||
* - 0 或 null:根菜单
|
||||
* - 其他值:子菜单
|
||||
*/
|
||||
private Long parentId;
|
||||
|
||||
/**
|
||||
* 菜单名称
|
||||
*/
|
||||
private String menuName;
|
||||
|
||||
/**
|
||||
* 菜单类型
|
||||
* - 1:目录(DIRECTORY)
|
||||
* - 2:菜单(MENU)
|
||||
* - 3:按钮(BUTTON)
|
||||
*/
|
||||
private Integer menuType;
|
||||
|
||||
/**
|
||||
* 菜单路径
|
||||
* - 用于前端路由
|
||||
*/
|
||||
private String menuPath;
|
||||
|
||||
/**
|
||||
* 组件路径
|
||||
* - 前端组件文件路径
|
||||
*/
|
||||
private String component;
|
||||
|
||||
/**
|
||||
* 菜单图标
|
||||
* - 图标名称
|
||||
*/
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* - 数值越小越靠前
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 可见性
|
||||
* - 1:可见
|
||||
* - 0:不可见
|
||||
*/
|
||||
private Integer visible;
|
||||
|
||||
/**
|
||||
* 菜单状态
|
||||
* - 1:启用
|
||||
* - 0:禁用
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* - 用于控制菜单的访问权限
|
||||
*/
|
||||
private String permissionCode;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 子菜单列表
|
||||
* - 用于构建菜单树形结构
|
||||
* - 递归结构
|
||||
*/
|
||||
private List<MenuVo> children;
|
||||
|
||||
public MenuVo() {
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getParentId() {
|
||||
return parentId;
|
||||
}
|
||||
|
||||
public void setParentId(Long parentId) {
|
||||
this.parentId = parentId;
|
||||
}
|
||||
|
||||
public String getMenuName() {
|
||||
return menuName;
|
||||
}
|
||||
|
||||
public void setMenuName(String menuName) {
|
||||
this.menuName = menuName;
|
||||
}
|
||||
|
||||
public Integer getMenuType() {
|
||||
return menuType;
|
||||
}
|
||||
|
||||
public void setMenuType(Integer menuType) {
|
||||
this.menuType = menuType;
|
||||
}
|
||||
|
||||
public String getMenuPath() {
|
||||
return menuPath;
|
||||
}
|
||||
|
||||
public void setMenuPath(String menuPath) {
|
||||
this.menuPath = menuPath;
|
||||
}
|
||||
|
||||
public String getComponent() {
|
||||
return component;
|
||||
}
|
||||
|
||||
public void setComponent(String component) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
public String getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public void setIcon(String icon) {
|
||||
this.icon = icon;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public Integer getVisible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
public void setVisible(Integer visible) {
|
||||
this.visible = visible;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getPermissionCode() {
|
||||
return permissionCode;
|
||||
}
|
||||
|
||||
public void setPermissionCode(String permissionCode) {
|
||||
this.permissionCode = permissionCode;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public List<MenuVo> getChildren() {
|
||||
return children;
|
||||
}
|
||||
|
||||
public void setChildren(List<MenuVo> children) {
|
||||
this.children = children;
|
||||
}
|
||||
}
|
||||
154
src/main/java/com/aisi/template/domain/vo/PermissionVo.java
Normal file
154
src/main/java/com/aisi/template/domain/vo/PermissionVo.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package com.aisi.template.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 权限视图对象
|
||||
* 用于返回权限信息给前端
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:权限ID、权限编码、权限名称
|
||||
* 2. 资源操作:资源名称、操作类型
|
||||
* 3. 描述和状态:描述信息、启用状态
|
||||
* 4. 时间信息:创建时间、更新时间
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class PermissionVo {
|
||||
|
||||
/**
|
||||
* 权限ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 权限编码
|
||||
* - 格式:资源:操作
|
||||
* - 示例:user:read, role:write
|
||||
*/
|
||||
private String permissionCode;
|
||||
|
||||
/**
|
||||
* 权限名称
|
||||
* - 用于界面展示
|
||||
* - 示例:查看用户、编辑角色
|
||||
*/
|
||||
private String permissionName;
|
||||
|
||||
/**
|
||||
* 资源名称
|
||||
* - 示例:user, role, menu
|
||||
*/
|
||||
private String resource;
|
||||
|
||||
/**
|
||||
* 操作类型
|
||||
* - read:读取(查看)
|
||||
* - write:写入(创建/编辑)
|
||||
* - delete:删除
|
||||
*/
|
||||
private String action;
|
||||
|
||||
/**
|
||||
* 权限描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 权限状态
|
||||
* - 1:启用
|
||||
* - 0:禁用
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PermissionVo() {
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getPermissionCode() {
|
||||
return permissionCode;
|
||||
}
|
||||
|
||||
public void setPermissionCode(String permissionCode) {
|
||||
this.permissionCode = permissionCode;
|
||||
}
|
||||
|
||||
public String getPermissionName() {
|
||||
return permissionName;
|
||||
}
|
||||
|
||||
public void setPermissionName(String permissionName) {
|
||||
this.permissionName = permissionName;
|
||||
}
|
||||
|
||||
public String getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public void setResource(String resource) {
|
||||
this.resource = resource;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
152
src/main/java/com/aisi/template/domain/vo/RoleVo.java
Normal file
152
src/main/java/com/aisi/template/domain/vo/RoleVo.java
Normal file
@@ -0,0 +1,152 @@
|
||||
package com.aisi.template.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 角色视图对象
|
||||
* 用于返回角色信息给前端
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:角色ID、角色编码、角色名称、描述
|
||||
* 2. 排序和状态:排序字段、启用状态
|
||||
* 3. 权限信息:关联的权限列表
|
||||
* 4. 时间信息:创建时间、更新时间
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class RoleVo {
|
||||
|
||||
/**
|
||||
* 角色ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 角色编码
|
||||
* - 格式:ROLE_ + 名称
|
||||
* - 示例:ROLE_USER, ROLE_ADMIN
|
||||
*/
|
||||
private String roleCode;
|
||||
|
||||
/**
|
||||
* 角色名称
|
||||
* - 用于界面展示
|
||||
* - 示例:普通用户、管理员
|
||||
*/
|
||||
private String roleName;
|
||||
|
||||
/**
|
||||
* 角色描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
* - 数值越小越靠前
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 角色状态
|
||||
* - 1:启用
|
||||
* - 0:禁用
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 角色拥有的权限列表
|
||||
*/
|
||||
private Set<PermissionVo> permissions;
|
||||
|
||||
public RoleVo() {
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getRoleCode() {
|
||||
return roleCode;
|
||||
}
|
||||
|
||||
public void setRoleCode(String roleCode) {
|
||||
this.roleCode = roleCode;
|
||||
}
|
||||
|
||||
public String getRoleName() {
|
||||
return roleName;
|
||||
}
|
||||
|
||||
public void setRoleName(String roleName) {
|
||||
this.roleName = roleName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public Set<PermissionVo> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public void setPermissions(Set<PermissionVo> permissions) {
|
||||
this.permissions = permissions;
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,73 @@ import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户视图对象
|
||||
* 用于返回用户信息给前端
|
||||
*
|
||||
* 主要字段:
|
||||
* 1. 基本信息:用户ID、用户名、邮箱
|
||||
* 2. 状态信息:启用/禁用状态
|
||||
* 3. 角色信息:角色编码列表
|
||||
* 4. 时间信息:创建时间、更新时间
|
||||
*
|
||||
* 注意:
|
||||
* - 不包含密码等敏感信息
|
||||
* - 角色只返回角色编码,不返回完整角色对象
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户视图对象")
|
||||
public class UserVo {
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@Schema(description = "用户ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 邮箱地址
|
||||
*/
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 用户状态
|
||||
* - 1:正常(启用)
|
||||
* - 0:禁用
|
||||
*/
|
||||
@Schema(description = "状态(1=正常 0=禁用)")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "角色(USER=普通用户,ADMIN=管理员)")
|
||||
private String role;
|
||||
/**
|
||||
* 角色编码列表
|
||||
* - 示例:["ROLE_USER", "ROLE_ADMIN"]
|
||||
* - 只返回角色编码,不返回完整角色对象
|
||||
*/
|
||||
@Schema(description = "角色列表")
|
||||
private Set<String> roles;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Schema(description = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Schema(description = "更新时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
536
src/main/java/com/aisi/template/examples/RedisUsageExample.java
Normal file
536
src/main/java/com/aisi/template/examples/RedisUsageExample.java
Normal file
@@ -0,0 +1,536 @@
|
||||
package com.aisi.template.examples;
|
||||
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.utils.RedisCache;
|
||||
import com.aisi.template.utils.RedisLock;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Redis 使用示例
|
||||
* 展示各种场景下的 Redis 使用方法
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RedisUsageExample {
|
||||
|
||||
private final RedisCache redisCache;
|
||||
private final RedisLock redisLock;
|
||||
|
||||
// ==================== 场景 1: 对象缓存 ====================
|
||||
|
||||
/**
|
||||
* 场景:缓存用户对象
|
||||
* 步骤:
|
||||
* 1. 先从缓存获取
|
||||
* 2. 如果缓存不存在,从数据库查询
|
||||
* 3. 将查询结果存入缓存
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户对象
|
||||
*/
|
||||
public User getUserWithCache(Long userId) {
|
||||
// 1. 定义缓存键
|
||||
String cacheKey = "user:" + userId;
|
||||
|
||||
// 2. 先从缓存获取
|
||||
User cachedUser = redisCache.get(cacheKey, User.class);
|
||||
if (cachedUser != null) {
|
||||
log.info("从缓存获取用户 - userId: {}", userId);
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
// 3. 缓存不存在,从数据库查询(模拟)
|
||||
log.info("从数据库查询用户 - userId: {}", userId);
|
||||
User user = getUserFromDatabase(userId);
|
||||
|
||||
// 4. 存入缓存(30分钟过期)
|
||||
if (user != null) {
|
||||
redisCache.set(cacheKey, user, 30, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:更新用户后删除缓存
|
||||
* 步骤:
|
||||
* 1. 更新数据库
|
||||
* 2. 删除缓存(下次查询时重新加载)
|
||||
*
|
||||
* @param user 用户对象
|
||||
*/
|
||||
public void updateUser(User user) {
|
||||
// 1. 更新数据库(模拟)
|
||||
log.info("更新数据库 - userId: {}", user.getId());
|
||||
updateUserInDatabase(user);
|
||||
|
||||
// 2. 删除缓存
|
||||
String cacheKey = "user:" + user.getId();
|
||||
redisCache.delete(cacheKey);
|
||||
log.info("删除用户缓存 - userId: {}", user.getId());
|
||||
}
|
||||
|
||||
// ==================== 场景 2: 计数器 ====================
|
||||
|
||||
/**
|
||||
* 场景:文章阅读数计数
|
||||
* 步骤:
|
||||
* 1. 使用 Redis 原子自增
|
||||
* 2. 异步批量更新到数据库
|
||||
*
|
||||
* @param articleId 文章ID
|
||||
* @return 当前阅读数
|
||||
*/
|
||||
public long incrementArticleViewCount(Long articleId) {
|
||||
// 1. 定义计数器键
|
||||
String counterKey = "article:view:" + articleId;
|
||||
|
||||
// 2. 自增(原子操作)
|
||||
long count = redisCache.increment(counterKey, 1);
|
||||
|
||||
// 3. 每100次更新一次数据库(模拟)
|
||||
if (count % 100 == 0) {
|
||||
log.info("同步阅读数到数据库 - articleId: {}, count: {}", articleId, count);
|
||||
syncViewCountToDatabase(articleId, count);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:点赞/取消点赞
|
||||
* 步骤:
|
||||
* 1. 使用 Set 存储点赞用户ID
|
||||
* 2. 使用计数器记录点赞数
|
||||
*
|
||||
* @param articleId 文章ID
|
||||
* @param userId 用户ID
|
||||
* @param liked 是否点赞
|
||||
*/
|
||||
public void likeArticle(Long articleId, Long userId, boolean liked) {
|
||||
// 1. 定义点赞集合键和计数器键
|
||||
String likeSetKey = "article:like:" + articleId;
|
||||
String likeCountKey = "article:like:count:" + articleId;
|
||||
|
||||
if (liked) {
|
||||
// 2. 点赞:添加用户到集合,计数器+1
|
||||
redisCache.sAdd(likeSetKey, userId);
|
||||
redisCache.increment(likeCountKey, 1);
|
||||
log.info("点赞成功 - articleId: {}, userId: {}", articleId, userId);
|
||||
} else {
|
||||
// 3. 取消点赞:从集合移除用户,计数器-1
|
||||
redisCache.sRemove(likeSetKey, userId);
|
||||
redisCache.decrement(likeCountKey, 1);
|
||||
log.info("取消点赞 - articleId: {}, userId: {}", articleId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:检查是否已点赞
|
||||
*
|
||||
* @param articleId 文章ID
|
||||
* @param userId 用户ID
|
||||
* @return 是否已点赞
|
||||
*/
|
||||
public boolean isArticleLiked(Long articleId, Long userId) {
|
||||
String likeSetKey = "article:like:" + articleId;
|
||||
return redisCache.sIsMember(likeSetKey, userId);
|
||||
}
|
||||
|
||||
// ==================== 场景 3: 分布式锁 ====================
|
||||
|
||||
/**
|
||||
* 场景:防止重复提交
|
||||
* 步骤:
|
||||
* 1. 尝试获取锁
|
||||
* 2. 如果获取成功,执行业务逻辑
|
||||
* 3. 最后释放锁
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
*/
|
||||
public void processOrder(Long orderId) {
|
||||
// 1. 定义锁键
|
||||
String lockKey = "order:process:" + orderId;
|
||||
|
||||
// 2. 尝试获取锁(30秒过期)
|
||||
String lockValue = redisLock.tryLock(lockKey, 30);
|
||||
if (lockValue == null) {
|
||||
log.warn("订单正在处理中,请勿重复提交 - orderId: {}", orderId);
|
||||
throw new RuntimeException("订单正在处理中");
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 执行业务逻辑
|
||||
log.info("处理订单 - orderId: {}", orderId);
|
||||
processOrderBusiness(orderId);
|
||||
|
||||
} finally {
|
||||
// 4. 释放锁
|
||||
redisLock.unlock(lockKey, lockValue);
|
||||
log.info("释放订单锁 - orderId: {}", orderId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:库存扣减(防止超卖)
|
||||
* 步骤:
|
||||
* 1. 获取分布式锁
|
||||
* 2. 检查库存
|
||||
* 3. 扣减库存
|
||||
* 4. 释放锁
|
||||
*
|
||||
* @param productId 商品ID
|
||||
* @param quantity 数量
|
||||
* @return 是否扣减成功
|
||||
*/
|
||||
public boolean deductStock(Long productId, int quantity) {
|
||||
// 1. 定义锁键
|
||||
String lockKey = "product:stock:" + productId;
|
||||
|
||||
// 2. 尝试获取锁(10秒过期,等待5秒)
|
||||
String lockValue = redisLock.tryLock(lockKey, 10, 5000);
|
||||
if (lockValue == null) {
|
||||
log.warn("获取库存锁失败,请稍后重试 - productId: {}", productId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 获取当前库存
|
||||
String stockKey = "product:stock:" + productId;
|
||||
Object stockObj = redisCache.get(stockKey);
|
||||
int currentStock = stockObj != null ? (Integer) stockObj : getStockFromDatabase(productId);
|
||||
|
||||
// 4. 检查库存是否充足
|
||||
if (currentStock < quantity) {
|
||||
log.warn("库存不足 - productId: {}, current: {}, required: {}",
|
||||
productId, currentStock, quantity);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 扣减库存
|
||||
int newStock = currentStock - quantity;
|
||||
redisCache.set(stockKey, newStock, 1, TimeUnit.HOURS);
|
||||
|
||||
// 6. 异步更新数据库
|
||||
updateStockInDatabase(productId, newStock);
|
||||
|
||||
log.info("库存扣减成功 - productId: {}, old: {}, new: {}",
|
||||
productId, currentStock, newStock);
|
||||
return true;
|
||||
|
||||
} finally {
|
||||
// 7. 释放锁
|
||||
redisLock.unlock(lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 场景 4: 排行榜 ====================
|
||||
|
||||
/**
|
||||
* 场景:添加积分到排行榜
|
||||
* 步骤:
|
||||
* 1. 使用 ZSet 存储用户积分
|
||||
* 2. 自动按分数排序
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param score 积分
|
||||
*/
|
||||
public void addUserScore(Long userId, double score) {
|
||||
// 1. 定义排行榜键
|
||||
String rankKey = "leaderboard:user:score";
|
||||
|
||||
// 2. 添加到有序集合
|
||||
// 如果用户已存在,分数会累加
|
||||
redisCache.zIncrementScore(rankKey, userId, score);
|
||||
log.info("用户积分更新 - userId: {}, score: {}", userId, score);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:获取排行榜 TOP N
|
||||
* 步骤:
|
||||
* 1. 获取分数最高的前 N 个用户
|
||||
* 2. 返回倒序结果
|
||||
*
|
||||
* @param topN 前N名
|
||||
* @return 用户ID集合(按分数倒序)
|
||||
*/
|
||||
public Set<Object> getTopUsers(int topN) {
|
||||
// 1. 定义排行榜键
|
||||
String rankKey = "leaderboard:user:score";
|
||||
|
||||
// 2. 获取倒序排名前 N 的用户
|
||||
Set<Object> topUsers = redisCache.zRangeByScore(rankKey, Double.MIN_VALUE, Double.MAX_VALUE);
|
||||
|
||||
// 3. 由于 Redis 返回的是无序集合,这里简化处理
|
||||
// 实际应该使用 zReverseRange 按排名范围获取
|
||||
log.info("获取排行榜 TOP {}", topN);
|
||||
return topUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:获取用户排名
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 排名(从0开始)
|
||||
*/
|
||||
public Long getUserRank(Long userId) {
|
||||
String rankKey = "leaderboard:user:score";
|
||||
Long rank = redisCache.zReverseRank(rankKey, userId);
|
||||
return rank != null ? rank + 1 : null; // 转换为从1开始
|
||||
}
|
||||
|
||||
// ==================== 场景 5: 消息队列 ====================
|
||||
|
||||
/**
|
||||
* 场景:发送消息到队列
|
||||
* 步骤:
|
||||
* 1. 使用 List 作为队列
|
||||
* 2. 从右侧推入消息
|
||||
*
|
||||
* @param queueName 队列名称
|
||||
* @param message 消息内容
|
||||
*/
|
||||
public void sendMessage(String queueName, String message) {
|
||||
String queueKey = "queue:" + queueName;
|
||||
redisCache.lRightPush(queueKey, message);
|
||||
log.info("发送消息到队列 - queue: {}, message: {}", queueName, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:从队列消费消息
|
||||
* 步骤:
|
||||
* 1. 从左侧弹出消息
|
||||
* 2. 处理消息
|
||||
*
|
||||
* @param queueName 队列名称
|
||||
* @return 消息内容
|
||||
*/
|
||||
public String consumeMessage(String queueName) {
|
||||
String queueKey = "queue:" + queueName;
|
||||
Object message = redisCache.lLeftPop(queueKey);
|
||||
if (message != null) {
|
||||
log.info("从队列消费消息 - queue: {}, message: {}", queueName, message);
|
||||
return message.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 场景 6: 共同好友 ====================
|
||||
|
||||
/**
|
||||
* 场景:计算两个用户的共同好友
|
||||
* 步骤:
|
||||
* 1. 使用 Set 存储每个用户的好友列表
|
||||
* 2. 计算交集
|
||||
*
|
||||
* @param userId1 用户1 ID
|
||||
* @param userId2 用户2 ID
|
||||
* @return 共同好友ID集合
|
||||
*/
|
||||
public Set<Object> getCommonFriends(Long userId1, Long userId2) {
|
||||
// 1. 定义好友集合键
|
||||
String friendsKey1 = "user:friends:" + userId1;
|
||||
String friendsKey2 = "user:friends:" + userId2;
|
||||
|
||||
// 2. 计算交集
|
||||
Set<Object> commonFriends = redisCache.sIntersect(friendsKey1, friendsKey2);
|
||||
|
||||
log.info("共同好友 - userId1: {}, userId2: {}, count: {}",
|
||||
userId1, userId2, commonFriends.size());
|
||||
return commonFriends;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:添加好友
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param friendId 好友ID
|
||||
*/
|
||||
public void addFriend(Long userId, Long friendId) {
|
||||
String friendsKey = "user:friends:" + userId;
|
||||
redisCache.sAdd(friendsKey, friendId);
|
||||
log.info("添加好友 - userId: {}, friendId: {}", userId, friendId);
|
||||
}
|
||||
|
||||
// ==================== 场景 7: 用户标签 ====================
|
||||
|
||||
/**
|
||||
* 场景:添加用户标签
|
||||
* 步骤:
|
||||
* 1. 使用 Set 存储用户标签
|
||||
* 2. 自动去重
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tags 标签列表
|
||||
*/
|
||||
public void addUserTags(Long userId, String... tags) {
|
||||
String tagsKey = "user:tags:" + userId;
|
||||
redisCache.sAdd(tagsKey, (Object[]) tags);
|
||||
log.info("添加用户标签 - userId: {}, tags: {}", userId, List.of(tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:获取用户标签
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 标签集合
|
||||
*/
|
||||
public Set<Object> getUserTags(Long userId) {
|
||||
String tagsKey = "user:tags:" + userId;
|
||||
return redisCache.sMembers(tagsKey);
|
||||
}
|
||||
|
||||
// ==================== 场景 8: 用户Session ====================
|
||||
|
||||
/**
|
||||
* 场景:存储用户会话信息
|
||||
* 步骤:
|
||||
* 1. 使用 Hash 存储会话字段
|
||||
* 2. 设置过期时间
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param sessionId 会话ID
|
||||
* @param loginIp 登录IP
|
||||
*/
|
||||
public void storeUserSession(Long userId, String sessionId, String loginIp) {
|
||||
String sessionKey = "session:user:" + userId;
|
||||
|
||||
// 1. 存储会话信息
|
||||
redisCache.hSet(sessionKey, "sessionId", sessionId);
|
||||
redisCache.hSet(sessionKey, "loginIp", loginIp);
|
||||
redisCache.hSet(sessionKey, "loginTime", System.currentTimeMillis());
|
||||
|
||||
// 2. 设置过期时间(24小时)
|
||||
redisCache.expire(sessionKey, 24, TimeUnit.HOURS);
|
||||
|
||||
log.info("存储用户会话 - userId: {}, sessionId: {}", userId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:获取用户会话信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 会话信息
|
||||
*/
|
||||
public Map<Object, Object> getUserSession(Long userId) {
|
||||
String sessionKey = "session:user:" + userId;
|
||||
return redisCache.hGetAll(sessionKey);
|
||||
}
|
||||
|
||||
// ==================== 场景 9: 热点数据缓存 ====================
|
||||
|
||||
/**
|
||||
* 场景:缓存热点数据(如首页推荐)
|
||||
* 步骤:
|
||||
* 1. 从缓存获取
|
||||
* 2. 如果不存在,加载数据
|
||||
* 3. 设置较短的过期时间
|
||||
*
|
||||
* @return 推荐内容列表
|
||||
*/
|
||||
public List<String> getHotContent() {
|
||||
String cacheKey = "hot:content:home";
|
||||
|
||||
// 1. 从缓存获取
|
||||
Object cached = redisCache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return (List<String>) cached;
|
||||
}
|
||||
|
||||
// 2. 加载热点数据(模拟)
|
||||
List<String> hotContent = loadHotContentFromDatabase();
|
||||
|
||||
// 3. 存入缓存(5分钟过期,热点数据更新快)
|
||||
redisCache.set(cacheKey, hotContent, 5, TimeUnit.MINUTES);
|
||||
|
||||
return hotContent;
|
||||
}
|
||||
|
||||
// ==================== 场景 10: 限流(基于计数器)====================
|
||||
|
||||
/**
|
||||
* 场景:API 限流
|
||||
* 步骤:
|
||||
* 1. 使用计数器记录请求次数
|
||||
* 2. 设置过期时间
|
||||
* 3. 超过限制则拒绝
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param limit 限制次数
|
||||
* @param windowSeconds 时间窗口(秒)
|
||||
* @return 是否允许请求
|
||||
*/
|
||||
public boolean checkRateLimit(Long userId, int limit, int windowSeconds) {
|
||||
String limitKey = "ratelimit:user:" + userId;
|
||||
|
||||
// 1. 获取当前计数
|
||||
Object countObj = redisCache.get(limitKey);
|
||||
int currentCount = countObj != null ? (Integer) countObj : 0;
|
||||
|
||||
// 2. 检查是否超过限制
|
||||
if (currentCount >= limit) {
|
||||
log.warn("用户请求超限 - userId: {}, count: {}, limit: {}",
|
||||
userId, currentCount, limit);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 增加计数
|
||||
if (currentCount == 0) {
|
||||
// 第一次请求,设置过期时间
|
||||
redisCache.set(limitKey, 1, windowSeconds, TimeUnit.SECONDS);
|
||||
} else {
|
||||
// 非第一次,直接自增
|
||||
redisCache.increment(limitKey, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 以下是模拟的辅助方法 ====================
|
||||
|
||||
private User getUserFromDatabase(Long userId) {
|
||||
// 模拟数据库查询
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setUsername("user" + userId);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void updateUserInDatabase(User user) {
|
||||
// 模拟数据库更新
|
||||
}
|
||||
|
||||
private void syncViewCountToDatabase(Long articleId, long count) {
|
||||
// 模拟同步到数据库
|
||||
}
|
||||
|
||||
private void processOrderBusiness(Long orderId) {
|
||||
// 模拟订单处理
|
||||
}
|
||||
|
||||
private int getStockFromDatabase(Long productId) {
|
||||
// 模拟获取库存
|
||||
return 1000;
|
||||
}
|
||||
|
||||
private void updateStockInDatabase(Long productId, int stock) {
|
||||
// 模拟更新库存
|
||||
}
|
||||
|
||||
private List<String> loadHotContentFromDatabase() {
|
||||
// 模拟加载热点数据
|
||||
return List.of("content1", "content2", "content3");
|
||||
}
|
||||
}
|
||||
540
src/main/java/com/aisi/template/examples/TransactionExample.java
Normal file
540
src/main/java/com/aisi/template/examples/TransactionExample.java
Normal file
@@ -0,0 +1,540 @@
|
||||
package com.aisi.template.examples;
|
||||
|
||||
import com.aisi.template.domain.entity.SysRole;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.repository.SysRoleRepository;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 事务使用示例
|
||||
* 展示 Spring 事务的各种传播行为和使用场景
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionExample {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final SysRoleRepository roleRepository;
|
||||
|
||||
// ==================== 场景 1: 基本事务使用 ====================
|
||||
|
||||
/**
|
||||
* 场景:创建用户并分配角色
|
||||
* 步骤:
|
||||
* 1. 开启事务
|
||||
* 2. 创建用户
|
||||
* 3. 分配角色
|
||||
* 4. 提交事务
|
||||
* 注意:任何步骤失败都会回滚整个事务
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @param roleIds 角色ID集合
|
||||
* @return 创建的用户ID
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createUserWithRoles(String username, String password, Set<Long> roleIds) {
|
||||
log.info("开始创建用户并分配角色 - username: {}", username);
|
||||
|
||||
try {
|
||||
// 1. 创建用户
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPassword(password);
|
||||
User savedUser = userRepository.save(user);
|
||||
log.info("用户创建成功 - userId: {}", savedUser.getId());
|
||||
|
||||
// 2. 分配角色
|
||||
for (Long roleId : roleIds) {
|
||||
SysRole role = roleRepository.findById(roleId)
|
||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
||||
user.getRoles().add(role);
|
||||
}
|
||||
userRepository.save(user);
|
||||
log.info("角色分配成功 - userId: {}, roleIds: {}", savedUser.getId(), roleIds);
|
||||
|
||||
// 3. 事务提交(方法正常结束时自动提交)
|
||||
return savedUser.getId();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 4. 发生异常,事务回滚
|
||||
log.error("创建用户失败,事务回滚 - username: {}", username, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 场景 2: REQUIRED 传播行为 ====================
|
||||
|
||||
/**
|
||||
* 场景:REQUIRED 传播行为(默认)
|
||||
* 说明:
|
||||
* - 如果当前没有事务,就新建一个事务
|
||||
* - 如果当前已经存在一个事务,就加入到这个事务中
|
||||
* 使用场景:大多数业务场景
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
|
||||
public void requiredExample(Long userId) {
|
||||
log.info("REQUIRED 传播行为示例 - userId: {}", userId);
|
||||
// 业务逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:嵌套事务示例
|
||||
* 步骤:
|
||||
* 1. 外层方法创建事务
|
||||
* 2. 调用内层方法(加入外层事务)
|
||||
* 3. 内外层任一异常,整体回滚
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
|
||||
public void nestedTransactionExample(Long userId) {
|
||||
log.info("外层事务开始 - userId: {}", userId);
|
||||
|
||||
// 1. 执行业务逻辑
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("new@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// 2. 调用内层方法(会加入当前事务)
|
||||
innerMethod(userId);
|
||||
|
||||
// 3. 外层事务提交
|
||||
log.info("外层事务提交 - userId: {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内层方法(加入外层事务)
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
|
||||
public void innerMethod(Long userId) {
|
||||
log.info("内层方法执行 - userId: {}", userId);
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setUsername("updated_username");
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
// ==================== 场景 3: REQUIRES_NEW 传播行为 ====================
|
||||
|
||||
/**
|
||||
* 场景:REQUIRES_NEW 传播行为
|
||||
* 说明:
|
||||
* - 无论当前是否有事务,都会创建新事务
|
||||
* - 如果当前存在事务,将当前事务挂起
|
||||
* 使用场景:独立记录日志,不受主事务影响
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||
public void requiresNewExample(Long userId) {
|
||||
log.info("REQUIRES_NEW 传播行为示例 - userId: {}", userId);
|
||||
// 独立事务,即使外层事务回滚也不影响
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:主事务 + 独立事务
|
||||
* 步骤:
|
||||
* 1. 主事务执行业务逻辑
|
||||
* 2. 独立事务记录日志
|
||||
* 3. 主事务失败不影响日志记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
|
||||
public void mainTransactionWithLog(Long userId) {
|
||||
log.info("主事务开始 - userId: {}", userId);
|
||||
|
||||
try {
|
||||
// 1. 执行业务逻辑
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("main@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// 2. 记录操作日志(独立事务)
|
||||
recordOperationLog(userId, "更新用户邮箱");
|
||||
|
||||
// 3. 模拟异常
|
||||
// throw new RuntimeException("模拟异常");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("主事务异常", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录操作日志(独立事务)
|
||||
* 即使主事务回滚,日志也会被记录
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||
public void recordOperationLog(Long userId, String operation) {
|
||||
log.info("记录操作日志(独立事务)- userId: {}, operation: {}", userId, operation);
|
||||
// 日志记录逻辑
|
||||
}
|
||||
|
||||
// ==================== 场景 4: NESTED 传播行为 ====================
|
||||
|
||||
/**
|
||||
* 场景:NESTED 传播行为
|
||||
* 说明:
|
||||
* - 如果当前存在事务,则嵌套在该事务中执行
|
||||
* - 嵌套事务是外层事务的一部分,可以独立提交
|
||||
* - 外层事务失败时,嵌套事务也会回滚
|
||||
* 使用场景:批量操作中部分可独立回滚
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
|
||||
public void nestedExample(Long userId) {
|
||||
log.info("NESTED 传播行为示例 - userId: {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:嵌套事务使用
|
||||
* 步骤:
|
||||
* 1. 外层事务更新用户基本信息
|
||||
* 2. 嵌套事务更新扩展信息
|
||||
* 3. 嵌套事务可以独立回滚(savepoint)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
|
||||
public void nestedTransactionUsage(Long userId) {
|
||||
log.info("外层事务开始 - userId: {}", userId);
|
||||
|
||||
// 1. 更新基本信息
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("basic@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
try {
|
||||
// 2. 嵌套事务更新扩展信息(创建 savepoint)
|
||||
updateExtendedInfo(userId);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 3. 嵌套事务异常不影响外层事务
|
||||
log.warn("扩展信息更新失败,继续执行", e);
|
||||
}
|
||||
|
||||
log.info("外层事务继续 - userId: {}", userId);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
|
||||
public void updateExtendedInfo(Long userId) {
|
||||
log.info("嵌套事务执行 - userId: {}", userId);
|
||||
// 扩展信息更新
|
||||
}
|
||||
|
||||
// ==================== 场景 5: 事务回滚 ====================
|
||||
|
||||
/**
|
||||
* 场景:异常时回滚事务
|
||||
* 步骤:
|
||||
* 1. 执行多个数据库操作
|
||||
* 2. 发生异常时自动回滚
|
||||
* 3. rollbackFor 指定哪些异常触发回滚
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(rollbackFor = {RuntimeException.class, Exception.class})
|
||||
public void rollbackExample(Long userId) {
|
||||
log.info("事务回滚示例 - userId: {}", userId);
|
||||
|
||||
// 1. 执行第一个操作
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("rollback@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// 2. 模拟异常,触发回滚
|
||||
if (true) {
|
||||
throw new RuntimeException("模拟异常,触发事务回滚");
|
||||
}
|
||||
|
||||
// 3. 这行代码不会执行
|
||||
user.setUsername("never_reached");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:指定回滚异常
|
||||
* 说明:
|
||||
* - 默认只对 RuntimeException 和 Error 回滚
|
||||
* - 需要对 checked exception 回滚时,指定 rollbackFor
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @throws Exception 业务异常
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void rollbackForCheckedException(Long userId) throws Exception {
|
||||
log.info("指定回滚异常示例 - userId: {}", userId);
|
||||
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("checked@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// 抛出 checked exception 也会回滚
|
||||
throw new Exception("业务异常");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:排除回滚异常
|
||||
* 说明:
|
||||
* - 某些异常不触发回滚
|
||||
* - 使用 noRollbackFor 指定
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(noRollbackFor = {BusinessException.class}, rollbackFor = Exception.class)
|
||||
public void noRollbackForExample(Long userId) {
|
||||
log.info("排除回滚异常示例 - userId: {}", userId);
|
||||
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("no_rollback@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// BusinessException 不会触发回滚
|
||||
throw new BusinessException("业务异常,但不回滚");
|
||||
}
|
||||
|
||||
// ==================== 场景 6: 只读事务 ====================
|
||||
|
||||
/**
|
||||
* 场景:只读事务
|
||||
* 说明:
|
||||
* - 设置事务为只读,优化查询性能
|
||||
* - 数据库可以进行查询优化
|
||||
* 使用场景:查询方法、报表统计
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户对象
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public User readOnlyExample(Long userId) {
|
||||
log.info("只读事务示例 - userId: {}", userId);
|
||||
return userRepository.findById(userId).orElseThrow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:批量查询(只读事务)
|
||||
* 步骤:
|
||||
* 1. 开启只读事务
|
||||
* 2. 执行多个查询
|
||||
* 3. 关闭事务
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户信息
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public UserInfoDTO getUserInfo(Long userId) {
|
||||
log.info("批量查询示例 - userId: {}", userId);
|
||||
|
||||
// 1. 查询基本信息
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
|
||||
// 2. 查询角色信息
|
||||
Set<SysRole> roles = user.getRoles();
|
||||
|
||||
// 3. 组装返回
|
||||
UserInfoDTO dto = new UserInfoDTO();
|
||||
dto.setUserId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setRoleCount(roles.size());
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
// ==================== 场景 7: 事务超时 ====================
|
||||
|
||||
/**
|
||||
* 场景:设置事务超时
|
||||
* 说明:
|
||||
* - 超过指定时间后自动回滚
|
||||
* - 单位:秒
|
||||
* 使用场景:长时间运行的事务
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(timeout = 30, rollbackFor = Exception.class)
|
||||
public void timeoutExample(Long userId) throws InterruptedException {
|
||||
log.info("事务超时示例 - userId: {}", userId);
|
||||
|
||||
// 模拟长时间操作
|
||||
Thread.sleep(1000);
|
||||
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("timeout@example.com");
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
// ==================== 场景 8: 编程式事务 ====================
|
||||
|
||||
/**
|
||||
* 场景:编程式事务(手动控制事务)
|
||||
* 步骤:
|
||||
* 1. 手动开启事务
|
||||
* 2. 执行业务逻辑
|
||||
* 3. 手动提交或回滚
|
||||
* 使用场景:复杂的事务控制逻辑
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
public void programmaticTransaction(Long userId) {
|
||||
log.info("编程式事务示例 - userId: {}", userId);
|
||||
|
||||
// 通过 TransactionTemplate 手动控制事务
|
||||
// 实际使用时注入 TransactionTemplate
|
||||
// transactionTemplate.execute(status -> {
|
||||
// try {
|
||||
// User user = userRepository.findById(userId).orElseThrow();
|
||||
// user.setEmail("programmatic@example.com");
|
||||
// userRepository.save(user);
|
||||
// return true;
|
||||
// } catch (Exception e) {
|
||||
// status.setRollbackOnly();
|
||||
// throw e;
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
// ==================== 场景 9: 事务后置处理 ====================
|
||||
|
||||
/**
|
||||
* 场景:事务提交后执行操作
|
||||
* 步骤:
|
||||
* 1. 执行业务逻辑
|
||||
* 2. 注册事务同步回调
|
||||
* 3. 事务提交后自动执行回调
|
||||
* 使用场景:发送消息、清除缓存、发布事件
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void afterCommitExample(Long userId) {
|
||||
log.info("事务后置处理示例 - userId: {}", userId);
|
||||
|
||||
// 1. 更新数据库
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setEmail("after_commit@example.com");
|
||||
userRepository.save(user);
|
||||
|
||||
// 2. 注册事务提交后回调
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
// 3. 事务提交后执行(如发送消息)
|
||||
log.info("事务已提交,发送通知 - userId: {}", userId);
|
||||
sendMessage(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
// 4. 事务完成后执行(无论成功或失败)
|
||||
log.info("事务已完成 - userId: {}, status: {}", userId, status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景:事务回滚后执行操作
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void afterRollbackExample(Long userId) {
|
||||
log.info("事务回滚后处理示例 - userId: {}", userId);
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (STATUS_ROLLED_BACK == status) {
|
||||
// 事务回滚后执行
|
||||
log.error("事务已回滚 - userId: {}", userId);
|
||||
handleRollback(userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 模拟异常
|
||||
throw new RuntimeException("模拟异常");
|
||||
}
|
||||
|
||||
// ==================== 场景 10: 隔离级别 ====================
|
||||
|
||||
/**
|
||||
* 场景:设置事务隔离级别
|
||||
* 说明:
|
||||
* - READ_UNCOMMITTED: 读未提交(可能出现脏读)
|
||||
* - READ_COMMITTED: 读已提交(防止脏读,可能出现不可重复读)
|
||||
* - REPEATABLE_READ: 可重复读(防止脏读和不可重复读,可能出现幻读)
|
||||
* - SERIALIZABLE: 串行化(最高隔离级别,性能最差)
|
||||
* 使用场景:根据业务需求选择合适的隔离级别
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED,
|
||||
rollbackFor = Exception.class)
|
||||
public void isolationLevelExample(Long userId) {
|
||||
log.info("隔离级别示例 - userId: {}", userId);
|
||||
// 使用 READ_COMMITTED 隔离级别
|
||||
}
|
||||
|
||||
// ==================== 辅助类和方法 ====================
|
||||
|
||||
/**
|
||||
* 用户信息 DTO
|
||||
*/
|
||||
public static class UserInfoDTO {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private Integer roleCount;
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public void setRoleCount(Integer roleCount) {
|
||||
this.roleCount = roleCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常
|
||||
*/
|
||||
public static class BusinessException extends RuntimeException {
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(模拟)
|
||||
*/
|
||||
private void sendMessage(Long userId) {
|
||||
log.info("发送消息 - userId: {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理回滚(模拟)
|
||||
*/
|
||||
private void handleRollback(Long userId) {
|
||||
log.info("处理回滚 - userId: {}", userId);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,51 @@
|
||||
package com.aisi.template.exception;
|
||||
import com.aisi.template.domain.enums.ErrorCode;
|
||||
|
||||
import com.aisi.template.domain.RestCode;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 自定义业务异常
|
||||
* 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 业务逻辑校验失败
|
||||
* - 资源不存在
|
||||
* - 权限不足
|
||||
* - 参数错误
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* // 指定错误码和消息
|
||||
* throw new BusinessException(404, "用户不存在");
|
||||
*
|
||||
* // 使用通用错误码(默认 400)
|
||||
* throw new BusinessException("操作失败");
|
||||
*
|
||||
* // 使用错误码枚举
|
||||
* throw new BusinessException(RestCode.USER_NOT_FOUND);
|
||||
* </pre>
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Getter // 使用 Lombok 自动生成 getCode() 方法
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
// 错误码 (例如 400, 403, 1001 等)
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
* - 例如:400, 403, 1001 等
|
||||
*/
|
||||
private final int code;
|
||||
|
||||
/**
|
||||
* 构造方法 1:手动指定 code 和 message
|
||||
* 使用:throw new BusinessException(404, "找不到该新闻");
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* throw new BusinessException(404, "找不到该新闻");
|
||||
* </pre>
|
||||
*
|
||||
* @param code 错误码
|
||||
* @param message 错误消息
|
||||
*/
|
||||
public BusinessException(int code, String message) {
|
||||
super(message); // 把 message 传给父类,方便 log 打印
|
||||
@@ -23,17 +53,30 @@ public class BusinessException extends RuntimeException {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造方法 2:使用通用错误码 (默认为 400 或 500)
|
||||
* 使用:throw new BusinessException("操作失败");
|
||||
* 构造方法 2:使用通用错误码
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* throw new BusinessException("操作失败");
|
||||
* </pre>
|
||||
*
|
||||
* @param message 错误消息
|
||||
*/
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
this.code = 400; // 默认给个 400
|
||||
this.code = 400; // 默认给个 400(客户端错误)
|
||||
}
|
||||
|
||||
// 在 BusinessException 类里添加这个构造方法
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.code = errorCode.getCode();
|
||||
/**
|
||||
* 构造方法 3:使用错误码枚举
|
||||
* 使用示例:
|
||||
* <pre>
|
||||
* throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
* </pre>
|
||||
*
|
||||
* @param restCode 错误码枚举
|
||||
*/
|
||||
public BusinessException(RestCode restCode) {
|
||||
super(restCode.getMessage());
|
||||
this.code = restCode.getCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.aisi.template.exception;
|
||||
|
||||
/**
|
||||
* 限流超出异常
|
||||
* 当请求频率超过限制时抛出此异常
|
||||
*
|
||||
* 使用场景:
|
||||
* - 登录接口:防止暴力破解
|
||||
* - API 接口:防止恶意刷接口
|
||||
* - 抢购活动:防止刷单
|
||||
*
|
||||
* HTTP 状态码:429 Too Many Requests
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public class RateLimitExceededException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* 构造方法:自定义错误消息
|
||||
*
|
||||
* @param message 错误消息
|
||||
*/
|
||||
public RateLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造方法:默认错误消息
|
||||
* 错误消息:"请求过于频繁,请稍后再试"
|
||||
*/
|
||||
public RateLimitExceededException() {
|
||||
super("请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.aisi.template.filter;
|
||||
|
||||
|
||||
import com.aisi.template.domain.CustomUserDetails;
|
||||
import com.aisi.template.service.TokenService;
|
||||
import com.aisi.template.service.impl.CustomUserDetailsService;
|
||||
import com.aisi.template.utils.JwtUtil;
|
||||
import jakarta.servlet.FilterChain;
|
||||
@@ -18,50 +18,133 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JWT 认证过滤器
|
||||
* 拦截请求并验证 JWT Token
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. Token 提取:从请求头中提取 JWT Token
|
||||
* 2. Token 验证:验证 Token 签名和有效性
|
||||
* 3. 黑名单检查:检查 Token 是否在黑名单中
|
||||
* 4. 用户认证:设置 Spring Security 认证信息
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. 从 Authorization 请求头中提取 Token
|
||||
* 2. 验证 Token 签名
|
||||
* 3. 检查 Token 是否在黑名单中
|
||||
* 4. 检查 Token 是否过期
|
||||
* 5. 提取用户信息并设置认证
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
* 用于生成和验证 JWT Token
|
||||
*/
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* 用户详情服务
|
||||
* 用于加载用户信息
|
||||
*/
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
/**
|
||||
* Token 服务
|
||||
* 用于检查 Token 黑名单
|
||||
*/
|
||||
private final TokenService tokenService;
|
||||
|
||||
/**
|
||||
* 过滤器内部处理方法
|
||||
* 步骤:
|
||||
* 1. 从请求头中提取 Token
|
||||
* 2. 验证 Token 签名
|
||||
* 3. 检查 Token 是否在黑名单中
|
||||
* 4. 检查 Token 是否过期
|
||||
* 5. 提取用户信息并设置认证
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param response HTTP 响应
|
||||
* @param filterChain 过滤器链
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = extractToken(request);
|
||||
if (token != null && jwtUtil.validateToken(token)) {
|
||||
// token 有效
|
||||
// 1. 从请求头中提取 Token
|
||||
String token = extractToken(request);
|
||||
|
||||
if (token != null && jwtUtil.validateToken(token)) {
|
||||
// 2. 验证 Token 签名通过,检查是否在黑名单中
|
||||
String jti = jwtUtil.extractJti(token);
|
||||
if (jti != null && tokenService.isTokenBlacklisted(jti)) {
|
||||
// 2.1 Token 已被撤销(在黑名单中),清除认证
|
||||
SecurityContextHolder.clearContext();
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 检查 Token 是否过期
|
||||
if (jwtUtil.isTokenExpired(token)) {
|
||||
// 3.1 Token 已过期,清除认证
|
||||
SecurityContextHolder.clearContext();
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Token 有效,提取用户信息并设置认证
|
||||
String username = jwtUtil.extractUsername(token);
|
||||
if (username != null && !username.isEmpty()) {
|
||||
// 4.1 加载用户详情
|
||||
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
|
||||
if (userDetails == null) throw new UsernameNotFoundException("User not found");
|
||||
if (userDetails == null) throw new UsernameNotFoundException("用户不存在");
|
||||
|
||||
// 4.2 创建认证对象
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities()
|
||||
);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
// 4.3 设置认证信息到 SecurityContext
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
}
|
||||
} else {
|
||||
// token 缺失或无效时,不抛异常,直接放行到下一个过滤器
|
||||
}
|
||||
} else {
|
||||
// 5. Token 缺失或无效,不抛出异常,继续处理
|
||||
// 原因:有些接口不需要认证(如登录、注册)
|
||||
}
|
||||
|
||||
// 6. 继续过滤器链
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取出token
|
||||
* 从 Authorization 请求头中提取 Token
|
||||
* 步骤:
|
||||
* 1. 获取 Authorization 请求头
|
||||
* 2. 检查是否为空
|
||||
* 3. 检查是否以 "Bearer " 开头
|
||||
* 4. 提取 Token 部分(去掉 "Bearer " 前缀)
|
||||
*
|
||||
* @return token
|
||||
* @param request HTTP 请求
|
||||
* @return JWT Token,如果无效则返回 null
|
||||
*/
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization == null) {
|
||||
return null;
|
||||
}
|
||||
if (!authorization.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
return authorization.substring(7);
|
||||
}
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
// 1. 获取 Authorization 请求头
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 检查是否以 "Bearer " 开头
|
||||
if (!authorization.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 提取 Token 部分(去掉 "Bearer " 前缀,共 7 个字符)
|
||||
return authorization.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aisi.template.handler;
|
||||
|
||||
import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.RestCode;
|
||||
import com.aisi.template.exception.RateLimitExceededException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||
|
||||
@@ -21,124 +22,194 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全局异常处理器
|
||||
* 统一处理系统中的各种异常,返回标准的错误响应格式
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 客户端请求错误(400):参数校验失败、JSON格式错误、缺少参数等
|
||||
* 2. 业务逻辑错误:自定义业务异常、数据冲突等
|
||||
* 3. 系统错误(500):空指针、未预期的异常等
|
||||
*
|
||||
* 异常分类:
|
||||
* - 客户端错误:记录 WARN 日志,不打印堆栈
|
||||
* - 业务异常:记录 WARN 日志,不打印堆栈
|
||||
* - 系统错误:记录 ERROR 日志,打印堆栈
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
// ==========================================
|
||||
// 1. 客户端请求错误 (400 Bad Request 等)
|
||||
// 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈
|
||||
// ==========================================
|
||||
|
||||
// ==================== 1. 客户端请求错误 (400 Bad Request 等) ====================
|
||||
// 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈
|
||||
|
||||
/**
|
||||
* 1. 参数校验失败异常 (@Valid / @Validated)
|
||||
* 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求
|
||||
* 处理参数校验失败异常
|
||||
* 场景:
|
||||
* - 前端传的 JSON 缺字段
|
||||
* - 字段不符合 @NotNull, @Size 等注解要求
|
||||
* - 使用 @Valid 或 @Validated 触发
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
||||
public RestBean<String> handleValidationException(Exception e) {
|
||||
// 1. 获取绑定结果
|
||||
BindingResult bindingResult = null;
|
||||
if (e instanceof MethodArgumentNotValidException) {
|
||||
bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
|
||||
} else if (e instanceof BindException) {
|
||||
bindingResult = ((BindException) e).getBindingResult();
|
||||
}
|
||||
|
||||
// 提取具体的错误信息(例如:"email: 邮箱格式不正确")
|
||||
|
||||
// 2. 提取具体的错误信息(例如:"email: 邮箱格式不正确")
|
||||
String msg = "参数校验失败";
|
||||
if (bindingResult != null) {
|
||||
msg = bindingResult.getFieldErrors().stream()
|
||||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
|
||||
log.warn("参数校验未通过: {}", msg);
|
||||
return RestBean.failure(RestCode.FAILURE, msg);
|
||||
return RestBean.failure(RestCode.FAILURE.getCode(), msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. JSON 格式解析错误
|
||||
* 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段
|
||||
* 处理 JSON 格式解析错误
|
||||
* 场景:
|
||||
* - 前端传的 JSON 少了括号
|
||||
* - 把 String 传给了 Integer 类型的字段
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public RestBean<String> handleJsonParseException(HttpMessageNotReadableException e) {
|
||||
log.warn("JSON解析失败: {}", e.getMessage());
|
||||
return RestBean.failure(RestCode.FAILURE, "请求Body格式错误,请检查JSON语法");
|
||||
log.warn("JSON 解析失败: {}", e.getMessage());
|
||||
return RestBean.failure(RestCode.FAILURE.getCode(), "请求 Body 格式错误,请检查 JSON 语法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 缺少必要的 URL 参数
|
||||
* 场景:接口定义了 @RequestParam(required=true) 但前端没传
|
||||
* 处理缺少必要的 URL 参数异常
|
||||
* 场景:
|
||||
* - 接口定义了 @RequestParam(required=true) 但前端没传
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public RestBean<String> handleMissingParam(MissingServletRequestParameterException e) {
|
||||
log.warn("缺少请求参数: {}", e.getParameterName());
|
||||
return RestBean.failure(RestCode.FAILURE, "缺少必要参数: " + e.getParameterName());
|
||||
return RestBean.failure(RestCode.FAILURE.getCode(), "缺少必要参数: " + e.getParameterName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 请求方法不支持
|
||||
* 场景:接口只写了 @PostMapping,前端却用 GET 请求访问
|
||||
* 处理请求方法不支持异常
|
||||
* 场景:
|
||||
* - 接口只写了 @PostMapping,前端却用 GET 请求访问
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public RestBean<String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
|
||||
log.warn("请求方法不支持: method={}, supported={}", e.getMethod(), e.getSupportedHttpMethods());
|
||||
return RestBean.failure(RestCode.METHOD_NOT_SUPPORT, "不支持该请求方法: " + e.getMethod());
|
||||
return RestBean.failure(RestCode.METHOD_NOT_SUPPORT.getCode(), "不支持该请求方法: " + e.getMethod());
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 参数类型不匹配 (刚才你遇到的那个)
|
||||
* 处理参数类型不匹配异常
|
||||
* 场景:
|
||||
* - 接口定义了 Integer 类型,前端传了字符串
|
||||
* - 接口定义了 Boolean 类型,前端传了非布尔值
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public RestBean<String> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
|
||||
String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName());
|
||||
log.warn("参数类型不匹配: {}", msg);
|
||||
return RestBean.failure(RestCode.FAILURE, msg);
|
||||
return RestBean.failure(RestCode.FAILURE.getCode(), msg);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 业务逻辑与数据库错误
|
||||
// ==========================================
|
||||
|
||||
// ==================== 2. 业务逻辑与数据库错误 ====================
|
||||
|
||||
/**
|
||||
* 6. 自定义业务异常 (最常用!)
|
||||
* 场景:你在 Service 层手动抛出 throw new BusinessException(403, "权限不足");
|
||||
* 处理自定义业务异常(最常用)
|
||||
* 场景:
|
||||
* - 在 Service 层手动抛出 throw new BusinessException(403, "权限不足")
|
||||
* - 业务逻辑校验失败
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public RestBean<String> handleBusinessException(BusinessException e) {
|
||||
// 业务异常通常是预期内的,记录 WARN 即可
|
||||
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
|
||||
return RestBean.failure(e.getCode(), e.getMessage(),"");
|
||||
return RestBean.failure(e.getCode(), e.getMessage(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 数据库唯一键冲突
|
||||
* 场景:注册时用户名已存在,插入数据库时触发 Unique Constraint
|
||||
* 处理数据库唯一键冲突异常
|
||||
* 场景:
|
||||
* - 注册时用户名已存在
|
||||
* - 插入数据库时触发 Unique Constraint
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(DuplicateKeyException.class)
|
||||
public RestBean<String> handleDuplicateKeyException(DuplicateKeyException e) {
|
||||
log.warn("数据库数据冲突: {}", e.getMessage());
|
||||
return RestBean.failure(RestCode.DATA_ALREADY_FOUND, "数据已存在,请勿重复操作");
|
||||
return RestBean.failure(RestCode.DATA_ALREADY_FOUND.getCode(), "数据已存在,请勿重复操作");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 致命系统错误 (500)
|
||||
// 这类错误是 Bug,必须记录堆栈信息 (e),并报警
|
||||
// ==========================================
|
||||
/**
|
||||
* 处理限流超出异常
|
||||
* 场景:
|
||||
* - 用户请求过于频繁,触发限流
|
||||
* - 超过接口调用的频率限制
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应(HTTP 429)
|
||||
*/
|
||||
@ExceptionHandler(RateLimitExceededException.class)
|
||||
public RestBean<String> handleRateLimitExceeded(RateLimitExceededException e) {
|
||||
log.warn("请求限流: {}", e.getMessage());
|
||||
return RestBean.failure(429, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
// ==================== 3. 致命系统错误 (500) ====================
|
||||
// 这类错误是 Bug,必须记录堆栈信息 (e),并报警
|
||||
|
||||
/**
|
||||
* 8. 空指针异常 (NullPointerException)
|
||||
* 场景:代码里没做判空,a.b() 时 a 是 null
|
||||
* 处理空指针异常
|
||||
* 场景:
|
||||
* - 代码里没做判空,a.b() 时 a 是 null
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(NullPointerException.class)
|
||||
public RestBean<String> handleNPE(NullPointerException e) {
|
||||
// 必须打印堆栈!
|
||||
log.error("发生空指针异常: ", e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员");
|
||||
log.error("发生空指针异常: ", e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR.getCode(), "系统内部数据异常,请联系管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限拒绝异常
|
||||
*/
|
||||
* 场景:
|
||||
* - 用户访问了无权限的接口
|
||||
* - @PreAuthorize 注解校验失败
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(AuthorizationDeniedException.class)
|
||||
public RestBean<Void> handleAuthorizationDenied(AuthorizationDeniedException e) {
|
||||
log.warn("权限拒绝: {}", e.getMessage());
|
||||
@@ -146,7 +217,13 @@ public class GlobalExceptionHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理其他异常
|
||||
* 处理其他未捕获的异常
|
||||
* 场景:
|
||||
* - 系统中未预期的异常
|
||||
* - 兜底的异常处理
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 错误响应
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public RestBean<Void> handleException(Exception e) {
|
||||
|
||||
@@ -10,51 +10,117 @@ import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 日志拦截器
|
||||
* 记录所有 API 请求的日志信息
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 请求前记录:记录请求开始信息
|
||||
* 2. 请求后记录:记录请求完成信息和耗时
|
||||
* 3. 用户追踪:记录当前用户信息
|
||||
*
|
||||
* 日志内容:
|
||||
* - 请求ID:用于追踪整个请求链路
|
||||
* - 请求方法:GET, POST, PUT, DELETE
|
||||
* - 请求路径:API 路径
|
||||
* - 用户信息:用户ID、用户名
|
||||
* - 响应状态:HTTP 状态码
|
||||
* - 请求耗时:毫秒
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Component
|
||||
public class LoggingInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 日志记录器
|
||||
*/
|
||||
private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);
|
||||
|
||||
/**
|
||||
* 请求前处理
|
||||
* 步骤:
|
||||
* 1. 记录请求开始时间
|
||||
* 2. 生成请求ID(8位UUID)
|
||||
* 3. 获取当前用户信息
|
||||
* 4. 记录请求开始日志
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param response HTTP 响应
|
||||
* @param handler 处理器
|
||||
* @return true 表示继续执行,false 表示中断
|
||||
*/
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
// 1. 记录请求开始时间
|
||||
long startTime = System.currentTimeMillis();
|
||||
request.setAttribute("startTime", startTime);
|
||||
|
||||
// 2. 生成请求ID(8位UUID,便于日志追踪)
|
||||
String requestId = UUID.randomUUID().toString().substring(0, 8);
|
||||
request.setAttribute("requestId", requestId);
|
||||
|
||||
// 3. 获取当前用户信息
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
request.setAttribute("userId", userId);
|
||||
|
||||
String username = SecurityUtils.getUsername();
|
||||
request.setAttribute("username", username);
|
||||
|
||||
// 4. 记录 API 请求开始日志
|
||||
if (isApiRequest(request.getRequestURI())) {
|
||||
logger.info("[{}] API请求开始 -> method: {}, path: {}, userId: {}, username: {}",
|
||||
logger.info("[{}] API 请求开始 -> method: {}, path: {}, userId: {}, username: {}",
|
||||
requestId, request.getMethod(), request.getRequestURI(), userId, username);
|
||||
}
|
||||
return true; // ✅ 必须返回true才能继续执行请求
|
||||
return true; // 必须返回 true 才能继续执行请求
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求完成后处理
|
||||
* 步骤:
|
||||
* 1. 计算请求耗时
|
||||
* 2. 记录请求完成日志
|
||||
* 3. 如果有异常,记录异常日志
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param response HTTP 响应
|
||||
* @param handler 处理器
|
||||
* @param ex 异常(如果有)
|
||||
*/
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler, Exception ex) {
|
||||
// 1. 获取请求开始时间
|
||||
Long startTime = (Long) request.getAttribute("startTime");
|
||||
if (startTime != null && isApiRequest(request.getRequestURI())) {
|
||||
// 2. 计算请求耗时
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 3. 获取请求信息
|
||||
String requestId = (String) request.getAttribute("requestId");
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
String username = (String) request.getAttribute("username");
|
||||
|
||||
logger.info("[{}] API请求完成 -> method: {}, path: {}, status: {}, userId: {}, username: {}, duration: {}ms",
|
||||
// 4. 记录 API 请求完成日志
|
||||
logger.info("[{}] API 请求完成 -> method: {}, path: {}, status: {}, userId: {}, username: {}, duration: {}ms",
|
||||
requestId, request.getMethod(), request.getRequestURI(), response.getStatus(), userId, username, duration);
|
||||
|
||||
// 5. 如果有异常,记录异常日志
|
||||
if (ex != null) {
|
||||
logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 API 请求
|
||||
* 说明:
|
||||
* - API 请求的路径以 /api/ 开头
|
||||
*
|
||||
* @param path 请求路径
|
||||
* @return 是否为 API 请求
|
||||
*/
|
||||
private boolean isApiRequest(String path) {
|
||||
return path.startsWith("/api/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.aisi.template.mq.consumer;
|
||||
|
||||
import com.aisi.template.mq.message.UserMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 用户消息消费者
|
||||
* 从 RocketMQ 消费用户相关消息并处理
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 用户注册后发送欢迎邮件
|
||||
* 2. 用户登录后记录登录日志到数据库
|
||||
* 3. 用户更新后同步到其他系统(如数据仓库)
|
||||
* 4. 用户删除后清理关联数据
|
||||
*
|
||||
* 消费者组说明:
|
||||
* - consumerGroup: 消费者组名,同一个组内只有一个消费者能消费某条消息
|
||||
* - topic: 消费的主题名称
|
||||
* - consumeMode: 消费模式,CONCURRENTLY(并发消费)或 ORDERLY(顺序消费)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "rocketmq.name-server")
|
||||
@RocketMQMessageListener(
|
||||
// 消费者组名称
|
||||
consumerGroup = "user-consumer-group",
|
||||
// 订阅的主题名称
|
||||
topic = "${rocketmq.producer.user-topic:user-topic}",
|
||||
// 消费模式:并发消费,提高吞吐量
|
||||
consumeMode = org.apache.rocketmq.spring.annotation.ConsumeMode.CONCURRENTLY,
|
||||
// 消息模型:集群消费(广播消费为 BROADCASTING)
|
||||
messageModel = org.apache.rocketmq.spring.annotation.MessageModel.CLUSTERING
|
||||
)
|
||||
public class UserMessageConsumer implements org.apache.rocketmq.spring.core.RocketMQListener<UserMessage> {
|
||||
|
||||
/**
|
||||
* 消费消息的方法
|
||||
* 步骤:
|
||||
* 1. 接收消息对象
|
||||
* 2. 根据消息类型进行不同的处理
|
||||
* 3. 处理成功返回,处理失败抛出异常
|
||||
*
|
||||
* 注意事项:
|
||||
* - 如果方法正常返回,消息会被确认消费成功
|
||||
* - 如果抛出异常,消息会被重新消费(根据配置的重试次数)
|
||||
* - 消费幂等性:需要业务方保证,建议使用唯一ID去重
|
||||
*
|
||||
* @param message 消息对象
|
||||
*/
|
||||
@Override
|
||||
public void onMessage(UserMessage message) {
|
||||
try {
|
||||
log.info("收到用户消息 - messageId: {}, type: {}, userId: {}",
|
||||
message.getMessageId(), message.getMessageType(), message.getUserId());
|
||||
|
||||
// 1. 根据消息类型进行不同的处理
|
||||
switch (message.getMessageType()) {
|
||||
case "REGISTER":
|
||||
// 处理用户注册消息
|
||||
handleRegisterMessage(message);
|
||||
break;
|
||||
|
||||
case "LOGIN":
|
||||
// 处理用户登录消息
|
||||
handleLoginMessage(message);
|
||||
break;
|
||||
|
||||
case "LOGOUT":
|
||||
// 处理用户登出消息
|
||||
handleLogoutMessage(message);
|
||||
break;
|
||||
|
||||
case "UPDATE":
|
||||
// 处理用户更新消息
|
||||
handleUpdateMessage(message);
|
||||
break;
|
||||
|
||||
case "DELETE":
|
||||
// 处理用户删除消息
|
||||
handleDeleteMessage(message);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.warn("未知消息类型: {}", message.getMessageType());
|
||||
}
|
||||
|
||||
log.info("消息处理完成 - messageId: {}", message.getMessageId());
|
||||
|
||||
} catch (Exception e) {
|
||||
// 2. 处理失败,记录错误日志
|
||||
// 抛出异常会导致消息重新消费
|
||||
log.error("消息处理失败 - messageId: {}, error: {}", message.getMessageId(), e.getMessage(), e);
|
||||
throw new RuntimeException("消息处理失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户注册消息
|
||||
* 步骤:
|
||||
* 1. 发送欢迎邮件
|
||||
* 2. 初始化用户数据
|
||||
* 3. 记录注册日志
|
||||
*
|
||||
* @param message 用户消息
|
||||
*/
|
||||
private void handleRegisterMessage(UserMessage message) {
|
||||
log.info("处理用户注册消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
|
||||
|
||||
// 1. 发送欢迎邮件(示例)
|
||||
// 实际项目中这里应该调用邮件服务
|
||||
sendWelcomeEmail(message.getUsername(), message.getEmail());
|
||||
|
||||
// 2. 初始化用户数据(示例)
|
||||
// 例如:创建用户配置、初始化用户钱包等
|
||||
initializeUserData(message.getUserId());
|
||||
|
||||
// 3. 记录注册日志(示例)
|
||||
logRegistration(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户登录消息
|
||||
* 步骤:
|
||||
* 1. 记录登录日志到数据库
|
||||
* 2. 更新最后登录时间
|
||||
* 3. 检查异常登录(异地登录等)
|
||||
*
|
||||
* @param message 用户消息
|
||||
*/
|
||||
private void handleLoginMessage(UserMessage message) {
|
||||
log.info("处理用户登录消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
|
||||
|
||||
// 1. 记录登录日志
|
||||
saveLoginLog(message);
|
||||
|
||||
// 2. 更新最后登录时间
|
||||
updateLastLoginTime(message.getUserId());
|
||||
|
||||
// 3. 检查异常登录
|
||||
checkAbnormalLogin(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户登出消息
|
||||
*
|
||||
* @param message 用户消息
|
||||
*/
|
||||
private void handleLogoutMessage(UserMessage message) {
|
||||
log.info("处理用户登出消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
|
||||
// 清理用户缓存、登出其他设备等
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户更新消息
|
||||
* 步骤:
|
||||
* 1. 同步用户数据到其他系统
|
||||
* 2. 更新缓存
|
||||
* 3. 记录变更日志
|
||||
*
|
||||
* @param message 用户消息
|
||||
*/
|
||||
private void handleUpdateMessage(UserMessage message) {
|
||||
log.info("处理用户更新消息 - userId: {}, content: {}", message.getUserId(), message.getContent());
|
||||
|
||||
// 1. 同步数据到数据仓库
|
||||
syncToDataWarehouse(message);
|
||||
|
||||
// 2. 更新缓存
|
||||
evictUserCache(message.getUserId());
|
||||
|
||||
// 3. 记录变更日志
|
||||
logChange(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户删除消息
|
||||
*
|
||||
* @param message 用户消息
|
||||
*/
|
||||
private void handleDeleteMessage(UserMessage message) {
|
||||
log.info("处理用户删除消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
|
||||
|
||||
// 1. 清理用户关联数据
|
||||
// 2. 归档用户数据
|
||||
// 3. 清理缓存
|
||||
}
|
||||
|
||||
// ==================== 以下是辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 发送欢迎邮件
|
||||
*/
|
||||
private void sendWelcomeEmail(String username, String email) {
|
||||
log.info("发送欢迎邮件 - username: {}, email: {}", username, email);
|
||||
// TODO: 实现邮件发送逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户数据
|
||||
*/
|
||||
private void initializeUserData(Long userId) {
|
||||
log.info("初始化用户数据 - userId: {}", userId);
|
||||
// TODO: 实现数据初始化逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录注册日志
|
||||
*/
|
||||
private void logRegistration(UserMessage message) {
|
||||
log.info("记录注册日志 - userId: {}", message.getUserId());
|
||||
// TODO: 实现日志记录逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存登录日志
|
||||
*/
|
||||
private void saveLoginLog(UserMessage message) {
|
||||
log.info("保存登录日志 - userId: {}", message.getUserId());
|
||||
// TODO: 实现登录日志保存逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新最后登录时间
|
||||
*/
|
||||
private void updateLastLoginTime(Long userId) {
|
||||
log.info("更新最后登录时间 - userId: {}", userId);
|
||||
// TODO: 实现更新逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查异常登录
|
||||
*/
|
||||
private void checkAbnormalLogin(UserMessage message) {
|
||||
log.info("检查异常登录 - userId: {}", message.getUserId());
|
||||
// TODO: 实现异常登录检测逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步到数据仓库
|
||||
*/
|
||||
private void syncToDataWarehouse(UserMessage message) {
|
||||
log.info("同步数据到仓库 - userId: {}", message.getUserId());
|
||||
// TODO: 实现数据同步逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户缓存
|
||||
*/
|
||||
private void evictUserCache(Long userId) {
|
||||
log.info("清除用户缓存 - userId: {}", userId);
|
||||
// TODO: 实现缓存清理逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录变更日志
|
||||
*/
|
||||
private void logChange(UserMessage message) {
|
||||
log.info("记录变更日志 - userId: {}", message.getUserId());
|
||||
// TODO: 实现变更日志记录逻辑
|
||||
}
|
||||
}
|
||||
65
src/main/java/com/aisi/template/mq/message/UserMessage.java
Normal file
65
src/main/java/com/aisi/template/mq/message/UserMessage.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.aisi.template.mq.message;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户相关消息
|
||||
* 用于 RocketMQ 消息传输,实现 Serializable 接口以支持序列化
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserMessage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 消息唯一标识
|
||||
*/
|
||||
private String messageId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 邮箱
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 消息类型:REGISTER-注册, LOGIN-登录, LOGOUT-登出, UPDATE-更新, DELETE-删除
|
||||
*/
|
||||
private String messageType;
|
||||
|
||||
/**
|
||||
* 消息内容/详情
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 扩展信息(JSON 格式)
|
||||
*/
|
||||
private String extInfo;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.aisi.template.mq.producer;
|
||||
|
||||
import com.aisi.template.mq.message.UserMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 用户消息生产者
|
||||
* 负责向 RocketMQ 发送用户相关消息
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 用户注册成功后发送欢迎消息
|
||||
* 2. 用户登录后发送登录通知
|
||||
* 3. 用户登出后记录登出日志
|
||||
* 4. 用户信息更新后同步数据
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "rocketmq.name-server")
|
||||
public class UserMessageProducer {
|
||||
|
||||
/**
|
||||
* RocketMQ 模板,用于发送消息
|
||||
*/
|
||||
private final RocketMQTemplate rocketMQTemplate;
|
||||
|
||||
/**
|
||||
* 用户主题名称
|
||||
* 从配置文件读取,默认为 user-topic
|
||||
*/
|
||||
@Value("${rocketmq.producer.user-topic:user-topic}")
|
||||
private String userTopic;
|
||||
|
||||
/**
|
||||
* 构造函数注入依赖
|
||||
*
|
||||
* @param rocketMQTemplate RocketMQ 模板
|
||||
*/
|
||||
public UserMessageProducer(RocketMQTemplate rocketMQTemplate) {
|
||||
this.rocketMQTemplate = rocketMQTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户注册消息
|
||||
* 步骤:
|
||||
* 1. 构建用户消息对象
|
||||
* 2. 设置消息类型为 REGISTER
|
||||
* 3. 发送到 RocketMQ
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param email 邮箱
|
||||
*/
|
||||
public void sendRegisterMessage(Long userId, String username, String email) {
|
||||
// 1. 构建消息对象
|
||||
UserMessage message = UserMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.email(email)
|
||||
.messageType("REGISTER")
|
||||
.content("用户注册成功: " + username)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 2. 发送消息到 RocketMQ
|
||||
// syncSend: 同步发送,会等待发送结果
|
||||
// MessageBuilder.wrap: 用于构建 Spring Messaging 消息
|
||||
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
|
||||
|
||||
log.info("用户注册消息发送成功 - userId: {}, username: {}", userId, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户登录消息
|
||||
* 步骤:
|
||||
* 1. 构建用户消息对象
|
||||
* 2. 设置消息类型为 LOGIN
|
||||
* 3. 异步发送到 RocketMQ
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param ip 登录IP地址
|
||||
*/
|
||||
public void sendLoginMessage(Long userId, String username, String ip) {
|
||||
// 1. 构建消息对象
|
||||
UserMessage message = UserMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.messageType("LOGIN")
|
||||
.content("用户登录 - IP: " + ip)
|
||||
.createTime(LocalDateTime.now())
|
||||
.extInfo("{\"ip\":\"" + ip + "\"}")
|
||||
.build();
|
||||
|
||||
// 2. 异步发送消息
|
||||
// asyncSend: 不会阻塞当前线程,适合不需要立即知道发送结果的场景
|
||||
rocketMQTemplate.asyncSend(userTopic, MessageBuilder.withPayload(message).build(), null);
|
||||
|
||||
log.info("用户登录消息发送成功 - userId: {}, username: {}, ip: {}", userId, username, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户登出消息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void sendLogoutMessage(Long userId, String username) {
|
||||
UserMessage message = UserMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.messageType("LOGOUT")
|
||||
.content("用户登出")
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
|
||||
log.info("用户登出消息发送成功 - userId: {}, username: {}", userId, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户更新消息
|
||||
* 步骤:
|
||||
* 1. 构建用户消息对象
|
||||
* 2. 设置消息类型为 UPDATE
|
||||
* 3. 添加更新内容的扩展信息
|
||||
* 4. 发送到 RocketMQ
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param updateContent 更新内容描述
|
||||
*/
|
||||
public void sendUpdateMessage(Long userId, String username, String updateContent) {
|
||||
UserMessage message = UserMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.messageType("UPDATE")
|
||||
.content("用户信息更新: " + updateContent)
|
||||
.createTime(LocalDateTime.now())
|
||||
.extInfo("{\"updateContent\":\"" + updateContent + "\"}")
|
||||
.build();
|
||||
|
||||
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
|
||||
log.info("用户更新消息发送成功 - userId: {}, username: {}, content: {}", userId, username, updateContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户删除消息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void sendDeleteMessage(Long userId, String username) {
|
||||
UserMessage message = UserMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.messageType("DELETE")
|
||||
.content("用户已删除: " + username)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
|
||||
log.info("用户删除消息发送成功 - userId: {}, username: {}", userId, username);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,66 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 密码重置验证码数据访问接口
|
||||
* 定义密码重置验证码相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 验证码查询:按邮箱查找
|
||||
* 3. 验证码统计:统计请求次数
|
||||
*
|
||||
* 安全机制:
|
||||
* - 验证码哈希存储,不存储明文
|
||||
* - 支持有效期控制
|
||||
* - 支持尝试次数限制
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> {
|
||||
|
||||
/**
|
||||
* 查找邮箱的所有未使用验证码
|
||||
* 说明:
|
||||
* - 返回指定邮箱所有未使用的验证码
|
||||
* - 按创建时间倒序排列
|
||||
*
|
||||
* 使用场景:
|
||||
* - 撤销旧验证码时使用
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return 未使用的验证码列表
|
||||
*/
|
||||
List<PasswordResetCode> findByEmailAndUsedFalse(String email);
|
||||
|
||||
/**
|
||||
* 查找邮箱的最新未使用验证码
|
||||
* 说明:
|
||||
* - 返回指定邮箱最新的、未使用的验证码
|
||||
* - 按创建时间倒序排列,取第一条
|
||||
*
|
||||
* 使用场景:
|
||||
* - 验证密码重置验证码
|
||||
* - 检查验证码是否过期
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return 最新的验证码(可能为空)
|
||||
*/
|
||||
Optional<PasswordResetCode> findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email);
|
||||
|
||||
/**
|
||||
* 统计指定时间后邮箱的验证码请求数
|
||||
* 说明:
|
||||
* - 用于限制请求频率
|
||||
* - 防止验证码接口被滥用
|
||||
*
|
||||
* 使用场景:
|
||||
* - 检查 1 小时内请求次数(限制 5 次)
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param createdAt 请求时间(统计此时间之后的请求)
|
||||
* @return 请求数量
|
||||
*/
|
||||
long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.aisi.template.repository;
|
||||
|
||||
import com.aisi.template.domain.entity.RefreshToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Refresh Token 数据访问接口
|
||||
* 定义 Refresh Token 相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. Token 查询:按哈希值查找、按用户ID查找有效Token
|
||||
* 2. Token 清理:删除过期的Token
|
||||
* 3. Token 撤销:批量撤销用户的Token
|
||||
*
|
||||
* 安全说明:
|
||||
* - 数据库只存储 Token 的哈希值,不存储原始值
|
||||
* - 原始 Token 只在创建时返回给客户端
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||
|
||||
/**
|
||||
* 根据 Token 哈希值查找 Refresh Token
|
||||
* 说明:
|
||||
* - 数据库只存储哈希值
|
||||
* - 客户端传入原始值,需要先哈希再查询
|
||||
*
|
||||
* @param tokenHash Token 的 SHA-256 哈希值
|
||||
* @return Refresh Token 对象(可能为空)
|
||||
*/
|
||||
Optional<RefreshToken> findByTokenHash(String tokenHash);
|
||||
|
||||
/**
|
||||
* 查找用户的所有有效 Refresh Token
|
||||
* 说明:
|
||||
* - 有效 = 未过期 且 未撤销
|
||||
* - 用于展示用户的登录设备列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param now 当前时间
|
||||
* @return 有效 Refresh Token 列表
|
||||
*/
|
||||
@Query("SELECT rt FROM RefreshToken rt WHERE rt.userId = :userId AND rt.revoked = false AND rt.expiresAt > :now")
|
||||
List<RefreshToken> findValidTokensByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* 删除所有过期的 Refresh Token
|
||||
* 说明:
|
||||
* - 用于定期清理任务
|
||||
* - 建议通过定时任务每天执行
|
||||
*
|
||||
* @param expiresAt 过期时间(删除早于此时间的Token)
|
||||
*/
|
||||
void deleteByExpiresAtBefore(LocalDateTime expiresAt);
|
||||
|
||||
/**
|
||||
* 撤销用户的所有 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 将用户所有未撤销的 Token 标记为已撤销
|
||||
* 2. 记录撤销时间
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户修改密码后撤销所有 Token
|
||||
* - 用户登出所有设备
|
||||
* - 管理员强制用户下线
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param revokedAt 撤销时间
|
||||
*/
|
||||
@Query("UPDATE RefreshToken rt SET rt.revoked = true, rt.revokedAt = :revokedAt WHERE rt.userId = :userId AND rt.revoked = false")
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
void revokeAllUserTokens(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.aisi.template.repository;
|
||||
|
||||
import com.aisi.template.domain.entity.SysAuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统审计日志数据访问接口
|
||||
* 定义审计日志相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 日志查询:按用户、操作、资源、时间范围查询
|
||||
* 3. 动态查询:支持 Specification 动态条件查询
|
||||
* 4. 日志清理:删除过期日志
|
||||
*
|
||||
* 审计日志内容:
|
||||
* - 操作用户
|
||||
* - 操作类型(CREATE, UPDATE, DELETE)
|
||||
* - 操作资源(user, role, menu)
|
||||
* - 操作结果(成功/失败)
|
||||
* - 请求信息(IP地址、User-Agent)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysAuditLogRepository extends JpaRepository<SysAuditLog, Long>, JpaSpecificationExecutor<SysAuditLog> {
|
||||
|
||||
/**
|
||||
* 根据用户ID查找审计日志
|
||||
* 说明:
|
||||
* - 按创建时间倒序排序
|
||||
* - 用于查看用户的操作历史
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 审计日志列表
|
||||
*/
|
||||
List<SysAuditLog> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
/**
|
||||
* 根据操作类型查找审计日志
|
||||
* 说明:
|
||||
* - 按创建时间倒序排序
|
||||
* - 用于查看特定类型的操作记录
|
||||
*
|
||||
* @param action 操作类型(LOGIN, LOGOUT, CREATE, UPDATE, DELETE)
|
||||
* @return 审计日志列表
|
||||
*/
|
||||
List<SysAuditLog> findByActionOrderByCreatedAtDesc(String action);
|
||||
|
||||
/**
|
||||
* 根据资源查找审计日志
|
||||
* 说明:
|
||||
* - 按创建时间倒序排序
|
||||
* - 用于查看特定资源的变更历史
|
||||
*
|
||||
* @param resource 资源名称(user, role, menu, permission)
|
||||
* @return 审计日志列表
|
||||
*/
|
||||
List<SysAuditLog> findByResourceOrderByCreatedAtDesc(String resource);
|
||||
|
||||
/**
|
||||
* 根据时间范围查找审计日志
|
||||
* 说明:
|
||||
* - 按创建时间倒序排序
|
||||
* - 用于时间范围内的统计分析
|
||||
*
|
||||
* @param start 开始时间
|
||||
* @param end 结束时间
|
||||
* @return 审计日志列表
|
||||
*/
|
||||
List<SysAuditLog> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end);
|
||||
|
||||
/**
|
||||
* 删除指定日期之前的审计日志
|
||||
* 说明:
|
||||
* - 用于定期清理旧日志
|
||||
* - 建议通过定时任务定期执行
|
||||
*
|
||||
* @param date 日期(删除早于此日期的日志)
|
||||
*/
|
||||
void deleteByCreatedAtBefore(LocalDateTime date);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.aisi.template.repository;
|
||||
|
||||
import com.aisi.template.domain.entity.SysMenu;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 系统菜单数据访问接口
|
||||
* 定义菜单相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 菜单查询:按父ID、状态、类型查询
|
||||
* 3. 关联查询:查询角色的菜单列表
|
||||
*
|
||||
* 菜单类型:
|
||||
* - DIRECTORY(1):目录
|
||||
* - MENU(2):菜单
|
||||
* - BUTTON(3):按钮
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysMenuRepository extends JpaRepository<SysMenu, Long> {
|
||||
|
||||
/**
|
||||
* 根据父ID查找菜单
|
||||
* 说明:
|
||||
* - 按排序字段排序
|
||||
* - 用于构建菜单树
|
||||
*
|
||||
* @param parentId 父菜单ID(null 表示根菜单)
|
||||
* @return 子菜单列表
|
||||
*/
|
||||
List<SysMenu> findByParentIdOrderBySortOrder(Long parentId);
|
||||
|
||||
/**
|
||||
* 根据状态查找菜单
|
||||
* 说明:
|
||||
* - 只返回启用状态的菜单
|
||||
* - 按排序字段排序
|
||||
*
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @return 菜单列表
|
||||
*/
|
||||
List<SysMenu> findByStatusOrderBySortOrder(Integer status);
|
||||
|
||||
/**
|
||||
* 根据可见性、状态、类型查找菜单
|
||||
* 说明:
|
||||
* - 同时满足可见、启用、指定类型
|
||||
* - 按排序字段排序
|
||||
*
|
||||
* @param visible 可见性(1=可见,0=不可见)
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @param menuType 菜单类型(1=目录,2=菜单,3=按钮)
|
||||
* @return 菜单列表
|
||||
*/
|
||||
List<SysMenu> findByVisibleAndStatusAndMenuTypeOrderBySortOrder(Integer visible, Integer status, Integer menuType);
|
||||
|
||||
/**
|
||||
* 根据角色ID查找菜单
|
||||
* 步骤:
|
||||
* 1. 通过角色关联查询菜单
|
||||
* 2. 按排序字段排序
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 菜单集合
|
||||
*/
|
||||
@Query("SELECT m FROM SysMenu m JOIN m.roles r WHERE r.id = :roleId ORDER BY m.sortOrder")
|
||||
Set<SysMenu> findByRoleId(@Param("roleId") Long roleId);
|
||||
|
||||
/**
|
||||
* 根据多个角色ID查找菜单
|
||||
* 步骤:
|
||||
* 1. 查询这些角色拥有的所有菜单
|
||||
* 2. 使用 DISTINCT 去重
|
||||
* 3. 按排序字段排序
|
||||
*
|
||||
* 使用场景:
|
||||
* - 获取用户的所有菜单(用户有多个角色)
|
||||
*
|
||||
* @param roleIds 角色ID列表
|
||||
* @return 菜单集合
|
||||
*/
|
||||
@Query("SELECT DISTINCT m FROM SysMenu m JOIN m.roles r WHERE r.id IN :roleIds ORDER BY m.sortOrder")
|
||||
Set<SysMenu> findByRoleIds(@Param("roleIds") List<Long> roleIds);
|
||||
|
||||
/**
|
||||
* 根据多个角色ID和菜单类型查找菜单
|
||||
* 步骤:
|
||||
* 1. 查询这些角色拥有的指定类型菜单
|
||||
* 2. 只返回启用且可见的菜单
|
||||
* 3. 使用 DISTINCT 去重
|
||||
* 4. 按排序字段排序
|
||||
*
|
||||
* 使用场景:
|
||||
* - 获取用户的菜单树(只返回目录和菜单,不包括按钮)
|
||||
*
|
||||
* @param roleIds 角色ID列表
|
||||
* @param menuType 菜单类型(1=目录,2=菜单)
|
||||
* @return 菜单列表
|
||||
*/
|
||||
@Query("SELECT DISTINCT m FROM SysMenu m JOIN m.roles r WHERE r.id IN :roleIds AND m.menuType = :menuType AND m.status = 1 AND m.visible = 1 ORDER BY m.sortOrder")
|
||||
List<SysMenu> findByRoleIdsAndMenuType(@Param("roleIds") List<Long> roleIds, @Param("menuType") Integer menuType);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.aisi.template.repository;
|
||||
|
||||
import com.aisi.template.domain.entity.SysPermission;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 系统权限数据访问接口
|
||||
* 定义权限相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 权限查询:按编码、状态、资源、操作查询
|
||||
* 3. 关联查询:查询角色的权限列表
|
||||
*
|
||||
* 权限格式:
|
||||
* - 资源:操作,如 user:read, user:write
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysPermissionRepository extends JpaRepository<SysPermission, Long> {
|
||||
|
||||
/**
|
||||
* 根据权限编码查找权限
|
||||
* 说明:
|
||||
* - 权限编码是唯一的,如:user:read, role:write
|
||||
*
|
||||
* @param permissionCode 权限编码
|
||||
* @return 权限对象(可能为空)
|
||||
*/
|
||||
Optional<SysPermission> findByPermissionCode(String permissionCode);
|
||||
|
||||
/**
|
||||
* 根据状态查找权限
|
||||
* 使用场景:
|
||||
* - 获取所有启用的权限
|
||||
*
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @return 权限列表
|
||||
*/
|
||||
List<SysPermission> findByStatus(Integer status);
|
||||
|
||||
/**
|
||||
* 根据资源查找权限
|
||||
* 说明:
|
||||
* - 返回指定资源的所有操作权限
|
||||
* - 例如:resource="user" 返回 user:read, user:write, user:delete
|
||||
*
|
||||
* @param resource 资源名称(如:user, role, menu)
|
||||
* @return 权限列表
|
||||
*/
|
||||
List<SysPermission> findByResource(String resource);
|
||||
|
||||
/**
|
||||
* 根据操作类型查找权限
|
||||
* 说明:
|
||||
* - 返回拥有指定操作的所有资源权限
|
||||
* - 例如:action="read" 返回 user:read, role:read, menu:read 等
|
||||
*
|
||||
* @param action 操作类型(read, write, delete)
|
||||
* @return 权限列表
|
||||
*/
|
||||
List<SysPermission> findByAction(String action);
|
||||
|
||||
/**
|
||||
* 根据角色ID查找权限
|
||||
* 步骤:
|
||||
* 1. 通过角色关联查询权限
|
||||
* 2. 返回该角色拥有的所有权限
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 权限集合
|
||||
*/
|
||||
@Query("SELECT p FROM SysPermission p JOIN p.roles r WHERE r.id = :roleId")
|
||||
Set<SysPermission> findByRoleId(@Param("roleId") Long roleId);
|
||||
|
||||
/**
|
||||
* 根据多个角色ID查找权限
|
||||
* 步骤:
|
||||
* 1. 查询这些角色拥有的所有权限
|
||||
* 2. 使用 DISTINCT 去重
|
||||
*
|
||||
* 使用场景:
|
||||
* - 获取用户的所有权限(用户有多个角色)
|
||||
*
|
||||
* @param roleIds 角色ID列表
|
||||
* @return 权限集合
|
||||
*/
|
||||
@Query("SELECT DISTINCT p FROM SysPermission p JOIN p.roles r WHERE r.id IN :roleIds")
|
||||
Set<SysPermission> findByRoleIds(@Param("roleIds") List<Long> roleIds);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.aisi.template.repository;
|
||||
|
||||
import com.aisi.template.domain.entity.SysRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 系统角色数据访问接口
|
||||
* 定义角色相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 角色查询:按角色编码、状态查询
|
||||
* 3. 关联查询:预加载权限列表
|
||||
* 4. 动态查询:支持 Specification 动态条件查询
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysRoleRepository extends JpaRepository<SysRole, Long>, JpaSpecificationExecutor<SysRole> {
|
||||
|
||||
/**
|
||||
* 根据角色编码查找角色
|
||||
* 说明:
|
||||
* - 角色编码是唯一的,如:ROLE_USER, ROLE_ADMIN
|
||||
*
|
||||
* @param roleCode 角色编码
|
||||
* @return 角色对象(可能为空)
|
||||
*/
|
||||
Optional<SysRole> findByRoleCode(String roleCode);
|
||||
|
||||
/**
|
||||
* 根据状态查找角色
|
||||
* 使用场景:
|
||||
* - 获取所有启用的角色
|
||||
* - 获取所有禁用的角色
|
||||
*
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @return 角色列表
|
||||
*/
|
||||
List<SysRole> findByStatus(Integer status);
|
||||
|
||||
/**
|
||||
* 根据ID查找角色(预加载权限)
|
||||
* 步骤:
|
||||
* 1. 使用 LEFT JOIN FETCH 一次性加载角色及其权限
|
||||
* 2. 避免 N+1 查询问题
|
||||
*
|
||||
* 使用场景:
|
||||
* - 获取角色详情时需要返回权限列表
|
||||
* - 角色管理页面展示
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 角色对象(可能为空)
|
||||
*/
|
||||
@Query("SELECT r FROM SysRole r LEFT JOIN FETCH r.permissions WHERE r.id = :id")
|
||||
Optional<SysRole> findByIdWithPermissions(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 查找所有角色(预加载权限)
|
||||
* 步骤:
|
||||
* 1. 使用 LEFT JOIN FETCH 一次性加载所有角色及权限
|
||||
* 2. 使用 DISTINCT 去重
|
||||
* 3. 按排序字段排序
|
||||
*
|
||||
* 使用场景:
|
||||
* - 角色列表展示
|
||||
* - 角色选择下拉框
|
||||
*
|
||||
* @return 角色列表
|
||||
*/
|
||||
@Query("SELECT DISTINCT r FROM SysRole r LEFT JOIN FETCH r.permissions ORDER BY r.sortOrder")
|
||||
List<SysRole> findAllWithPermissions();
|
||||
}
|
||||
@@ -3,40 +3,168 @@ package com.aisi.template.repository;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
* 定义用户相关的数据库操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 基本操作:继承 CRUD 操作
|
||||
* 2. 用户查询:按用户名、邮箱查询
|
||||
* 3. 关联查询:使用 JOIN FETCH 解决懒加载问题
|
||||
* 4. 动态查询:支持 Specification 动态条件查询
|
||||
*
|
||||
* 注意事项:
|
||||
* - 使用 JOIN FETCH 避免懒加载时的 N+1 问题
|
||||
* - 邮箱查询支持不区分大小写
|
||||
* - 支持按状态筛选用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户(预加载角色)
|
||||
* 步骤:
|
||||
* 1. 使用 LEFT JOIN FETCH 一次性加载用户及其角色
|
||||
* 2. 避免 N+1 查询问题
|
||||
*
|
||||
* 使用场景:
|
||||
* - 登录时需要加载用户角色
|
||||
* - 获取用户信息时需要返回角色列表
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.username = :username")
|
||||
Optional<User> findByUsernameWithRoles(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户(预加载角色和权限)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.permissions WHERE u.username = :username")
|
||||
Optional<User> findByUsernameWithRolesAndPermissions(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 根据用户ID查找用户(预加载角色)
|
||||
* 步骤:
|
||||
* 1. 使用 LEFT JOIN FETCH 一次性加载用户及其角色
|
||||
* 2. 避免 N+1 查询问题
|
||||
*
|
||||
* 使用场景:
|
||||
* - 获取用户菜单时需要加载角色
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id")
|
||||
Optional<User> findByIdWithRoles(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 根据邮箱查找用户
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
/**
|
||||
* 根据邮箱查找用户(不区分大小写)
|
||||
* 说明:
|
||||
* - 数据库使用 LOWER() 函数进行比较
|
||||
* - 适用于用户输入邮箱大小写不确定的场景
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByEmailIgnoreCase(String email);
|
||||
|
||||
/**
|
||||
* 根据用户名和密码查找用户
|
||||
* 说明:
|
||||
* - 密码是已加密的哈希值
|
||||
* - 一般不使用此方法,应先查询用户再验证密码
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param password 密码哈希值
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByUsernameAndPassword(String username, String password);
|
||||
|
||||
/**
|
||||
* 根据邮箱和密码查找用户
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param password 密码哈希值
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByEmailAndPassword(String email, String password);
|
||||
|
||||
/**
|
||||
* 根据用户名查找启用的用户
|
||||
* 根据用户名和状态查找用户
|
||||
* 使用场景:
|
||||
* - 只查找启用状态的用户
|
||||
* - 只查找禁用状态的用户
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByUsernameAndStatus(String username, Integer status);
|
||||
|
||||
/**
|
||||
* 根据邮箱查找启用的用户
|
||||
* 根据邮箱和状态查找用户
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param status 状态(1=启用,0=禁用)
|
||||
* @return 用户对象(可能为空)
|
||||
*/
|
||||
Optional<User> findByEmailAndStatus(String email, Integer status);
|
||||
|
||||
/**
|
||||
* 检查用户名是否存在
|
||||
* 使用场景:
|
||||
* - 用户注册时校验用户名是否已被占用
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return true 表示用户名已存在
|
||||
*/
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
/**
|
||||
* 检查邮箱是否存在
|
||||
* 说明:
|
||||
* - 精确匹配,区分大小写
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return true 表示邮箱已存在
|
||||
*/
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
/**
|
||||
* 检查邮箱是否存在(不区分大小写)
|
||||
* 说明:
|
||||
* - 数据库使用 LOWER() 函数进行比较
|
||||
* - 适用于用户注册时校验邮箱
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @return true 表示邮箱已存在
|
||||
*/
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
}
|
||||
|
||||
65
src/main/java/com/aisi/template/service/AuditLogService.java
Normal file
65
src/main/java/com/aisi/template/service/AuditLogService.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
import com.aisi.template.domain.entity.SysAuditLog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审计日志服务接口
|
||||
* 定义审计日志的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 创建审计日志:记录用户操作
|
||||
* 2. 清理日志:删除过期的日志
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface AuditLogService {
|
||||
|
||||
/**
|
||||
* 创建审计日志
|
||||
*
|
||||
* @param auditLog 审计日志实体
|
||||
*/
|
||||
void createAuditLog(SysAuditLog auditLog);
|
||||
|
||||
/**
|
||||
* 记录审计日志(便捷方法)
|
||||
* 步骤:
|
||||
* 1. 创建审计日志实体
|
||||
* 2. 设置日志信息
|
||||
* 3. 保存到数据库
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param action 操作类型(LOGIN, LOGOUT, CREATE, UPDATE, DELETE)
|
||||
* @param resource 资源类型(user, role, menu)
|
||||
* @param resourceId 资源ID
|
||||
* @param description 操作描述
|
||||
* @param requestMethod 请求方法(GET, POST, PUT, DELETE)
|
||||
* @param requestUri 请求URI
|
||||
* @param ipAddress IP地址
|
||||
* @param userAgent User-Agent
|
||||
* @param status 操作状态(1=成功,0=失败)
|
||||
* @param errorMessage 错误信息(失败时)
|
||||
*/
|
||||
void log(Long userId, String username, String action, String resource,
|
||||
String resourceId, String description, String requestMethod,
|
||||
String requestUri, String ipAddress, String userAgent,
|
||||
Integer status, String errorMessage);
|
||||
|
||||
/**
|
||||
* 清理旧日志
|
||||
* 步骤:
|
||||
* 1. 计算过期时间
|
||||
* 2. 删除过期日志
|
||||
*
|
||||
* 使用场景:
|
||||
* - 定时任务定期清理
|
||||
* - 建议保留最近 90 天的日志
|
||||
*
|
||||
* @param daysToKeep 保留天数
|
||||
*/
|
||||
void cleanupOldLogs(int daysToKeep);
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
/**
|
||||
* 邮件服务接口
|
||||
* 定义邮件发送的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 发送密码重置验证码
|
||||
* 2. 发送注册欢迎邮件
|
||||
* 3. 发送通知邮件
|
||||
*
|
||||
* 邮件类型:
|
||||
* - 验证码邮件:包含 6 位数字验证码
|
||||
* - 欢迎邮件:欢迎用户注册
|
||||
* - 通知邮件:系统通知
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface EmailService {
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码邮件
|
||||
* 步骤:
|
||||
* 1. 构建邮件内容(包含验证码)
|
||||
* 2. 设置邮件主题和收件人
|
||||
* 3. 发送邮件
|
||||
*
|
||||
* @param email 收件人邮箱
|
||||
* @param code 6 位数字验证码
|
||||
*/
|
||||
void sendPasswordResetCode(String email, String code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
/**
|
||||
* 登录尝试服务接口
|
||||
* 定义登录失败次数和账户锁定的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 记录登录失败尝试
|
||||
* 2. 登录成功后重置失败计数
|
||||
* 3. 检查账户是否被锁定
|
||||
* 4. 锁定账户(手动锁定)
|
||||
* 5. 获取剩余锁定时间
|
||||
*
|
||||
* 安全机制:
|
||||
* - 连续失败 5 次锁定账户 30 分钟
|
||||
* - 登录成功后重置失败计数
|
||||
* - 锁定期间无法登录
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface LoginAttemptService {
|
||||
|
||||
/**
|
||||
* 记录登录失败尝试
|
||||
* 步骤:
|
||||
* 1. 查询用户当前失败次数
|
||||
* 2. 失败次数加 1
|
||||
* 3. 检查是否超过最大失败次数
|
||||
* 4. 如果超过,锁定账户并返回 true
|
||||
* 5. 保存更新到数据库
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return true 表示账户应该被锁定
|
||||
*/
|
||||
boolean recordFailedAttempt(String username);
|
||||
|
||||
/**
|
||||
* 重置登录失败计数
|
||||
* 步骤:
|
||||
* 1. 查询用户
|
||||
* 2. 重置失败计数为 0
|
||||
* 3. 清除锁定时间(如果有)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 登录成功后调用
|
||||
* - 管理员手动解锁后调用
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
void resetFailedAttempts(String username);
|
||||
|
||||
/**
|
||||
* 检查账户是否被锁定
|
||||
* 步骤:
|
||||
* 1. 查询用户的锁定时间和失败次数
|
||||
* 2. 判断是否在锁定期内
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return true 表示账户被锁定
|
||||
*/
|
||||
boolean isLocked(String username);
|
||||
|
||||
/**
|
||||
* 锁定账户
|
||||
* 步骤:
|
||||
* 1. 查询用户
|
||||
* 2. 设置锁定时间为当前时间 + 指定分钟数
|
||||
* 3. 保存更新到数据库
|
||||
*
|
||||
* 使用场景:
|
||||
* - 管理员手动锁定用户
|
||||
* - 自动锁定(由 recordFailedAttempt 触发)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param lockDurationMinutes 锁定时长(分钟)
|
||||
*/
|
||||
void lockAccount(String username, int lockDurationMinutes);
|
||||
|
||||
/**
|
||||
* 获取账户剩余锁定时间
|
||||
* 步骤:
|
||||
* 1. 查询用户的锁定时间
|
||||
* 2. 计算与当前时间的差值
|
||||
* 3. 转换为分钟
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 剩余锁定时间(分钟),未锁定返回 0
|
||||
*/
|
||||
Long getRemainingLockTimeMinutes(String username);
|
||||
}
|
||||
@@ -4,9 +4,51 @@ import com.aisi.template.domain.RestBean;
|
||||
import com.aisi.template.domain.dto.PasswordResetConfirmDto;
|
||||
import com.aisi.template.domain.dto.PasswordResetRequestDto;
|
||||
|
||||
/**
|
||||
* 密码重置服务接口
|
||||
* 定义密码重置的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 发送重置验证码:生成验证码并发送到用户邮箱
|
||||
* 2. 验证码重置密码:验证验证码并更新密码
|
||||
*
|
||||
* 安全机制:
|
||||
* - 验证码 6 位数字,10 分钟有效
|
||||
* - 同一邮箱 60 秒内只能请求一次
|
||||
* - 验证码最多尝试 5 次
|
||||
* - 新密码必须符合强度要求
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface PasswordResetService {
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码
|
||||
* 步骤:
|
||||
* 1. 校验邮箱格式
|
||||
* 2. 检查请求冷却时间(防止频繁请求)
|
||||
* 3. 生成 6 位随机数字验证码
|
||||
* 4. 将验证码哈希后存储到 Redis(10分钟有效)
|
||||
* 5. 发送验证码到用户邮箱
|
||||
*
|
||||
* @param requestDto 重置请求(邮箱)
|
||||
* @return 成功响应
|
||||
*/
|
||||
RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto);
|
||||
|
||||
/**
|
||||
* 使用验证码重置密码
|
||||
* 步骤:
|
||||
* 1. 校验邮箱和验证码是否匹配
|
||||
* 2. 检查验证码是否过期
|
||||
* 3. 校验验证码尝试次数(超过 5 次则验证码失效)
|
||||
* 4. 校验新密码强度
|
||||
* 5. 更新用户密码
|
||||
* 6. 删除已使用的验证码
|
||||
*
|
||||
* @param confirmDto 确认请求(邮箱、验证码、新密码)
|
||||
* @return 成功响应
|
||||
*/
|
||||
RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto);
|
||||
}
|
||||
|
||||
134
src/main/java/com/aisi/template/service/SysMenuService.java
Normal file
134
src/main/java/com/aisi/template/service/SysMenuService.java
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
import com.aisi.template.domain.dto.MenuDto;
|
||||
import com.aisi.template.domain.vo.MenuVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统菜单服务接口
|
||||
* 定义菜单相关的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 菜单管理:创建、更新、删除、查询菜单
|
||||
* 2. 菜单树构建:构建菜单的层级结构
|
||||
* 3. 用户菜单:根据用户角色获取可访问的菜单
|
||||
*
|
||||
* 菜单类型:
|
||||
* - DIRECTORY:目录(仅用于分组,不对应具体页面)
|
||||
* - MENU:菜单项(对应具体页面)
|
||||
* - BUTTON:按钮(页面内的操作按钮)
|
||||
*
|
||||
* 设计说明:
|
||||
* - 菜单采用树形结构(通过 parentId 自关联实现)
|
||||
* - 删除父菜单时会级联删除子菜单
|
||||
* - 菜单与角色是多对多关系
|
||||
* - 用户通过角色获得菜单访问权限
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysMenuService {
|
||||
|
||||
/**
|
||||
* 创建菜单
|
||||
* 步骤:
|
||||
* 1. 校验父菜单是否存在(如果是子菜单)
|
||||
* 2. 校验菜单路径是否重复
|
||||
* 3. 创建菜单实体
|
||||
* 4. 计算菜单层级和排序
|
||||
* 5. 保存到数据库
|
||||
*
|
||||
* @param menuDto 菜单信息
|
||||
* @return 创建的菜单视图对象
|
||||
*/
|
||||
MenuVo create(MenuDto menuDto);
|
||||
|
||||
/**
|
||||
* 更新菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 检查是否将菜单设置为自己的子菜单(循环引用)
|
||||
* 3. 更新菜单信息
|
||||
* 4. 保存到数据库
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @param menuDto 菜单信息
|
||||
* @return 更新后的菜单视图对象
|
||||
*/
|
||||
MenuVo update(Long id, MenuDto menuDto);
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 检查是否有子菜单
|
||||
* 3. 删除菜单与角色的关联关系
|
||||
* 4. 递归删除所有子菜单
|
||||
* 5. 删除当前菜单
|
||||
*
|
||||
* 注意:
|
||||
* - 删除父菜单会级联删除所有子菜单
|
||||
* - 删除操作需要谨慎,建议使用软删除
|
||||
*
|
||||
* @param id 菜单ID
|
||||
*/
|
||||
void delete(Long id);
|
||||
|
||||
/**
|
||||
* 根据ID获取菜单
|
||||
* 步骤:
|
||||
* 1. 查询菜单
|
||||
* 2. 转换为视图对象返回
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @return 菜单视图对象
|
||||
*/
|
||||
MenuVo getById(Long id);
|
||||
|
||||
/**
|
||||
* 获取菜单树
|
||||
* 步骤:
|
||||
* 1. 查询所有菜单
|
||||
* 2. 按父ID分组
|
||||
* 3. 递归构建树形结构
|
||||
* 4. 按排序字段排序
|
||||
*
|
||||
* 树形结构:
|
||||
* - 根节点(parentId = null)
|
||||
* - 子节点 1
|
||||
* - 子节点 1.1
|
||||
* - 子节点 2
|
||||
*
|
||||
* @return 菜单树列表
|
||||
*/
|
||||
List<MenuVo> getMenuTree();
|
||||
|
||||
/**
|
||||
* 获取用户菜单
|
||||
* 步骤:
|
||||
* 1. 查询用户的所有角色
|
||||
* 2. 查询角色关联的所有菜单
|
||||
* 3. 去重并构建树形结构
|
||||
* 4. 只返回有权限访问的菜单
|
||||
*
|
||||
* 使用场景:
|
||||
* - 前端导航菜单渲染
|
||||
* - 用户权限判断
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户可访问的菜单树
|
||||
*/
|
||||
List<MenuVo> getUserMenus(Long userId);
|
||||
|
||||
/**
|
||||
* 根据父ID获取子菜单
|
||||
* 步骤:
|
||||
* 1. 查询指定父ID下的所有子菜单
|
||||
* 2. 按排序字段排序
|
||||
*
|
||||
* @param parentId 父菜单ID(null 表示查询根菜单)
|
||||
* @return 子菜单列表
|
||||
*/
|
||||
List<MenuVo> getByParentId(Long parentId);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
import com.aisi.template.domain.vo.PermissionVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统权限服务接口
|
||||
* 定义权限相关的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 权限查询:获取所有权限、按条件查询
|
||||
* 2. 权限管理:权限通常由系统初始化,不支持动态创建/删除
|
||||
*
|
||||
* 权限格式:
|
||||
* - 资源:操作 格式,如 user:read, user:write
|
||||
* - 资源:系统中的实体,如 user, role, permission, menu
|
||||
* - 操作:read(读取)、write(写入)、delete(删除)
|
||||
*
|
||||
* 设计说明:
|
||||
* - 权限是系统中预定义的,通过数据库迁移脚本初始化
|
||||
* - 角色与权限是多对多关系
|
||||
* - 权限通过角色分配给用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysPermissionService {
|
||||
|
||||
/**
|
||||
* 获取所有权限
|
||||
* 步骤:
|
||||
* 1. 查询所有权限
|
||||
* 2. 按资源分组排序
|
||||
* 3. 转换为视图对象列表
|
||||
*
|
||||
* 使用场景:
|
||||
* - 权限分配界面
|
||||
* - 权限列表展示
|
||||
*
|
||||
* @return 所有权限视图对象列表
|
||||
*/
|
||||
List<PermissionVo> getAllPermissions();
|
||||
|
||||
/**
|
||||
* 根据ID获取权限
|
||||
* 步骤:
|
||||
* 1. 查询权限
|
||||
* 2. 转换为视图对象返回
|
||||
*
|
||||
* @param id 权限ID
|
||||
* @return 权限视图对象
|
||||
*/
|
||||
PermissionVo getById(Long id);
|
||||
|
||||
/**
|
||||
* 根据资源获取权限
|
||||
* 步骤:
|
||||
* 1. 按资源名称查询权限
|
||||
* 2. 返回该资源的所有操作权限
|
||||
*
|
||||
* 示例:
|
||||
* - resource="user" 返回 user:read, user:write, user:delete
|
||||
*
|
||||
* @param resource 资源名称
|
||||
* @return 该资源的所有权限
|
||||
*/
|
||||
List<PermissionVo> getByResource(String resource);
|
||||
|
||||
/**
|
||||
* 根据操作类型获取权限
|
||||
* 步骤:
|
||||
* 1. 按操作类型查询权限
|
||||
* 2. 返回拥有该操作的所有资源权限
|
||||
*
|
||||
* 示例:
|
||||
* - action="read" 返回 user:read, role:read, menu:read 等
|
||||
*
|
||||
* @param action 操作类型(read, write, delete)
|
||||
* @return 拥有该操作的所有权限
|
||||
*/
|
||||
List<PermissionVo> getByAction(String action);
|
||||
}
|
||||
139
src/main/java/com/aisi/template/service/SysRoleService.java
Normal file
139
src/main/java/com/aisi/template/service/SysRoleService.java
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
import com.aisi.template.domain.dto.RoleDto;
|
||||
import com.aisi.template.domain.dto.RoleQueryDto;
|
||||
import com.aisi.template.domain.dto.PageResult;
|
||||
import com.aisi.template.domain.vo.RoleVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统角色服务接口
|
||||
* 定义角色相关的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 角色管理:创建、更新、删除、查询角色
|
||||
* 2. 权限分配:为角色分配权限
|
||||
* 3. 权限查询:获取角色的权限列表
|
||||
*
|
||||
* 设计说明:
|
||||
* - 角色与权限是多对多关系
|
||||
* - 删除角色前需要检查是否有用户关联
|
||||
* - 超级管理员角色不可删除
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface SysRoleService {
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* 步骤:
|
||||
* 1. 校验角色编码是否已存在
|
||||
* 2. 创建角色实体
|
||||
* 3. 保存到数据库
|
||||
* 4. 转换为视图对象返回
|
||||
*
|
||||
* @param roleDto 角色信息(角色名、编码、描述)
|
||||
* @return 创建的角色视图对象
|
||||
*/
|
||||
RoleVo create(RoleDto roleDto);
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 检查新角色编码是否与其他角色冲突
|
||||
* 3. 更新角色信息
|
||||
* 4. 保存到数据库
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @param roleDto 角色信息
|
||||
* @return 更新后的角色视图对象
|
||||
*/
|
||||
RoleVo update(Long id, RoleDto roleDto);
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 检查是否有用户关联此角色
|
||||
* 3. 检查是否为超级管理员角色(禁止删除)
|
||||
* 4. 删除角色与权限的关联关系
|
||||
* 5. 删除角色
|
||||
*
|
||||
* @param id 角色ID
|
||||
*/
|
||||
void delete(Long id);
|
||||
|
||||
/**
|
||||
* 根据ID获取角色
|
||||
* 步骤:
|
||||
* 1. 查询角色
|
||||
* 2. 加载关联的权限列表
|
||||
* 3. 转换为视图对象返回
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 角色视图对象
|
||||
*/
|
||||
RoleVo getById(Long id);
|
||||
|
||||
/**
|
||||
* 获取所有角色
|
||||
* 步骤:
|
||||
* 1. 查询所有角色
|
||||
* 2. 转换为视图对象列表
|
||||
*
|
||||
* 使用场景:
|
||||
* - 角色下拉选择框
|
||||
* - 角色列表展示
|
||||
*
|
||||
* @return 所有角色视图对象列表
|
||||
*/
|
||||
List<RoleVo> getAllRoles();
|
||||
|
||||
/**
|
||||
* 分页查询角色
|
||||
* 步骤:
|
||||
* 1. 构建分页参数
|
||||
* 2. 构建动态查询条件(关键词、状态)
|
||||
* 3. 执行分页查询
|
||||
* 4. 转换结果为视图对象
|
||||
*
|
||||
* @param queryDto 查询条件
|
||||
* @param page 页码(从1开始)
|
||||
* @param size 每页大小
|
||||
* @return 分页角色列表
|
||||
*/
|
||||
PageResult<RoleVo> queryRoles(RoleQueryDto queryDto, int page, int size);
|
||||
|
||||
/**
|
||||
* 为角色分配权限
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 验证所有权限ID是否存在
|
||||
* 3. 清空角色原有权限
|
||||
* 4. 添加新权限
|
||||
* 5. 保存到数据库
|
||||
*
|
||||
* 注意:
|
||||
* - 此操作会完全替换角色的权限列表
|
||||
* - 权限变更后用户需要重新登录才能生效
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @param permissionIds 权限ID列表
|
||||
*/
|
||||
void assignPermissions(Long roleId, List<Long> permissionIds);
|
||||
|
||||
/**
|
||||
* 获取角色的权限ID列表
|
||||
* 步骤:
|
||||
* 1. 查询角色
|
||||
* 2. 加载关联的权限
|
||||
* 3. 提取权限ID列表
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 权限ID列表
|
||||
*/
|
||||
List<Long> getRolePermissionIds(Long roleId);
|
||||
}
|
||||
161
src/main/java/com/aisi/template/service/TokenService.java
Normal file
161
src/main/java/com/aisi/template/service/TokenService.java
Normal file
@@ -0,0 +1,161 @@
|
||||
package com.aisi.template.service;
|
||||
|
||||
import com.aisi.template.domain.dto.RefreshTokenDto;
|
||||
import com.aisi.template.domain.vo.LoginResponseVo;
|
||||
|
||||
/**
|
||||
* Token 服务接口
|
||||
* 定义 Refresh Token 和黑名单管理的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. Refresh Token 管理:创建、刷新、撤销 Refresh Token
|
||||
* 2. Token 黑名单:管理已撤销的 JWT Token
|
||||
* 3. 清理任务:清理过期的 Token
|
||||
*
|
||||
* Token 说明:
|
||||
* - Access Token:短期有效(默认 1 小时),用于 API 访问
|
||||
* - Refresh Token:长期有效(默认 7 天),用于刷新 Access Token
|
||||
* - Token 黑名单:用于实现登出功能,将已撤销的 Token 加入黑名单
|
||||
*
|
||||
* 安全机制:
|
||||
* - Refresh Token 存储在数据库,支持撤销
|
||||
* - Refresh Token 每次使用后生成新的(Token 轮换)
|
||||
* - 登出时将 Access Token 加入 Redis 黑名单
|
||||
* - 定时清理过期的 Token 和黑名单记录
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface TokenService {
|
||||
|
||||
/**
|
||||
* 创建 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 生成随机的 Token 字符串
|
||||
* 2. 计算 Token 的哈希值(SHA-256)
|
||||
* 3. 设置过期时间(当前时间 + 7 天)
|
||||
* 4. 保存到数据库
|
||||
* 5. 返回原始 Token(不存储原始值)
|
||||
*
|
||||
* 安全说明:
|
||||
* - 数据库只存储 Token 的哈希值,防止泄露
|
||||
* - Token 格式:UUID 随机字符串
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param deviceInfo 设备信息(可选)
|
||||
* @param ipAddress IP 地址(可选)
|
||||
* @return Refresh Token 原始值
|
||||
*/
|
||||
String createRefreshToken(Long userId, String deviceInfo, String ipAddress);
|
||||
|
||||
/**
|
||||
* 刷新 Access Token
|
||||
* 步骤:
|
||||
* 1. 计算 Refresh Token 的哈希值
|
||||
* 2. 查询数据库中的 Token 记录
|
||||
* 3. 检查 Token 是否存在、是否过期、是否被撤销
|
||||
* 4. 验证 Token 是否属于当前用户
|
||||
* 5. 生成新的 Access Token
|
||||
* 6. 生成新的 Refresh Token(Token 轮换)
|
||||
* 7. 撤销旧的 Refresh Token
|
||||
*
|
||||
* Token 轮换:
|
||||
* - 每次刷新时生成新的 Refresh Token
|
||||
* - 旧的 Refresh Token 立即失效
|
||||
* - 防止 Token 被重复使用
|
||||
*
|
||||
* @param refreshToken Refresh Token 原始值
|
||||
* @return 新的登录响应(包含新的 Access Token 和 Refresh Token)
|
||||
*/
|
||||
LoginResponseVo refreshToken(String refreshToken);
|
||||
|
||||
/**
|
||||
* 撤销 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 计算 Token 的哈希值
|
||||
* 2. 查询数据库中的 Token 记录
|
||||
* 3. 将撤销状态设置为 true
|
||||
* 4. 保存到数据库
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户主动登出
|
||||
* - Token 刷新时撤销旧 Token
|
||||
* - 管理员强制用户下线
|
||||
*
|
||||
* @param tokenHash Refresh Token 的哈希值
|
||||
*/
|
||||
void revokeRefreshToken(String tokenHash);
|
||||
|
||||
/**
|
||||
* 撤销用户的所有 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 查询用户的所有有效 Refresh Token
|
||||
* 2. 批量设置为撤销状态
|
||||
* 3. 保存到数据库
|
||||
*
|
||||
* 使用场景:
|
||||
* - 修改密码后撤销所有设备
|
||||
* - 管理员强制用户下线
|
||||
* - 用户重置所有登录会话
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void revokeAllUserTokens(Long userId);
|
||||
|
||||
/**
|
||||
* 检查 Token 是否在黑名单中
|
||||
* 步骤:
|
||||
* 1. 从 JWT Token 中提取 jti(JWT ID)
|
||||
* 2. 在 Redis 中查询黑名单
|
||||
* 3. 返回是否存在
|
||||
*
|
||||
* @param jti JWT ID
|
||||
* @return true 表示 Token 已被撤销(在黑名单中)
|
||||
*/
|
||||
boolean isTokenBlacklisted(String jti);
|
||||
|
||||
/**
|
||||
* 将 Token 加入黑名单
|
||||
* 步骤:
|
||||
* 1. 从 JWT Token 中提取 jti 和过期时间
|
||||
* 2. 计算剩余有效时间
|
||||
* 3. 在 Redis 中设置黑名单记录(带过期时间)
|
||||
*
|
||||
* 为什么需要黑名单:
|
||||
* - JWT Token 是无状态的,一旦签发无法主动撤销
|
||||
* - 通过黑名单机制实现登出功能
|
||||
* - 黑名单记录的过期时间与 Token 一致
|
||||
*
|
||||
* @param jti JWT ID
|
||||
* @param expirationSeconds 剩余有效时间(秒)
|
||||
*/
|
||||
void addTokenToBlacklist(String jti, long expirationSeconds);
|
||||
|
||||
/**
|
||||
* 清理过期的 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 查询所有过期时间小于当前时间的 Token
|
||||
* 2. 批量删除
|
||||
*
|
||||
* 使用场景:
|
||||
* - 定时任务(每天凌晨执行)
|
||||
* - 手动触发清理
|
||||
*
|
||||
* 注意:
|
||||
* - 已撤销的 Token 也需要清理
|
||||
* - 建议定期执行,防止数据堆积
|
||||
*/
|
||||
void cleanupExpiredTokens();
|
||||
|
||||
/**
|
||||
* 清理过期的黑名单记录
|
||||
* 步骤:
|
||||
* 1. 扫描 Redis 中的黑名单
|
||||
* 2. 删除已过期的记录
|
||||
*
|
||||
* 注意:
|
||||
* - Redis 的 key 会自动过期,此方法用于手动清理
|
||||
* - 一般不需要调用,除非需要立即释放内存
|
||||
*/
|
||||
void cleanupExpiredBlacklistEntries();
|
||||
}
|
||||
@@ -6,19 +6,101 @@ import com.aisi.template.domain.dto.UserDto;
|
||||
import com.aisi.template.domain.dto.UserQueryDto;
|
||||
import com.aisi.template.domain.dto.UserRoleUpdateDto;
|
||||
import com.aisi.template.domain.dto.UserStatusUpdateDto;
|
||||
import com.aisi.template.domain.vo.LoginResponseVo;
|
||||
import com.aisi.template.domain.vo.UserVo;
|
||||
|
||||
/**
|
||||
* 用户服务接口
|
||||
* 定义用户相关的业务操作
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 用户认证:注册、登录
|
||||
* 2. 用户信息:获取当前用户信息
|
||||
* 3. 用户管理:分页查询、状态更新、角色分配
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
public interface UserService {
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* 步骤:
|
||||
* 1. 从 SecurityContext 获取当前登录用户
|
||||
* 2. 查询用户详细信息
|
||||
* 3. 转换为视图对象返回
|
||||
*
|
||||
* @return 用户信息视图对象
|
||||
*/
|
||||
RestBean<UserVo> getUserInfo();
|
||||
|
||||
RestBean<String> register(UserDto userDto);
|
||||
/**
|
||||
* 用户注册
|
||||
* 步骤:
|
||||
* 1. 校验用户名、邮箱是否已存在
|
||||
* 2. 校验密码强度
|
||||
* 3. 创建用户并分配默认角色
|
||||
* 4. 生成 Access Token 和 Refresh Token
|
||||
* 5. 发送注册消息到 MQ
|
||||
*
|
||||
* @param userDto 用户注册信息
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
RestBean<LoginResponseVo> register(UserDto userDto);
|
||||
|
||||
RestBean<String> login(UserDto userDto);
|
||||
/**
|
||||
* 用户登录
|
||||
* 步骤:
|
||||
* 1. 验证用户名和密码
|
||||
* 2. 检查账户状态(是否被禁用、锁定)
|
||||
* 3. 记录登录失败次数(超过阈值则锁定)
|
||||
* 4. 登录成功后重置失败计数
|
||||
* 5. 生成 Access Token 和 Refresh Token
|
||||
* 6. 发送登录消息到 MQ
|
||||
*
|
||||
* @param userDto 登录信息(用户名、密码)
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
RestBean<LoginResponseVo> login(UserDto userDto);
|
||||
|
||||
/**
|
||||
* 分页查询用户列表
|
||||
* 步骤:
|
||||
* 1. 构建动态查询条件
|
||||
* 2. 支持按用户名/邮箱模糊搜索
|
||||
* 3. 支持按状态筛选
|
||||
* 4. 分页返回结果
|
||||
*
|
||||
* @param queryDto 查询条件
|
||||
* @return 分页用户列表
|
||||
*/
|
||||
RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto);
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* 步骤:
|
||||
* 1. 检查用户是否存在
|
||||
* 2. 禁止修改当前登录用户的状态
|
||||
* 3. 更新用户状态
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 状态更新请求
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto);
|
||||
|
||||
/**
|
||||
* 更新用户角色
|
||||
* 步骤:
|
||||
* 1. 检查用户是否存在
|
||||
* 2. 禁止修改当前登录用户的角色
|
||||
* 3. 验证角色ID是否存在
|
||||
* 4. 清空用户原有角色
|
||||
* 5. 添加新角色
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 角色更新请求
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.entity.SysAuditLog;
|
||||
import com.aisi.template.repository.SysAuditLogRepository;
|
||||
import com.aisi.template.service.AuditLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审计日志服务实现类
|
||||
* 负责审计日志的记录和清理
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 创建审计日志:记录用户操作
|
||||
* 2. 清理旧日志:定期删除过期日志
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 @Async 注解实现异步记录,不阻塞主流程
|
||||
* - 使用 @Transactional 保证数据一致性
|
||||
* - 异常捕获确保日志记录失败不影响业务
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogServiceImpl implements AuditLogService {
|
||||
|
||||
/**
|
||||
* 审计日志数据访问接口
|
||||
*/
|
||||
private final SysAuditLogRepository auditLogRepository;
|
||||
|
||||
/**
|
||||
* 创建审计日志(异步)
|
||||
* 步骤:
|
||||
* 1. 保存审计日志到数据库
|
||||
* 2. 捕获异常,确保记录失败不影响业务
|
||||
*
|
||||
* @param auditLog 审计日志实体
|
||||
*/
|
||||
@Async
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void createAuditLog(SysAuditLog auditLog) {
|
||||
try {
|
||||
auditLogRepository.save(auditLog);
|
||||
} catch (Exception e) {
|
||||
log.error("保存审计日志失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录审计日志(异步)
|
||||
* 步骤:
|
||||
* 1. 创建审计日志实体
|
||||
* 2. 设置日志信息
|
||||
* 3. 保存到数据库
|
||||
* 4. 捕获异常,确保记录失败不影响业务
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param action 操作类型(LOGIN, LOGOUT, CREATE, UPDATE, DELETE)
|
||||
* @param resource 资源类型(user, role, menu)
|
||||
* @param resourceId 资源ID
|
||||
* @param description 操作描述
|
||||
* @param requestMethod 请求方法(GET, POST, PUT, DELETE)
|
||||
* @param requestUri 请求URI
|
||||
* @param ipAddress IP地址
|
||||
* @param userAgent User-Agent
|
||||
* @param status 操作状态(1=成功,0=失败)
|
||||
* @param errorMessage 错误信息(失败时)
|
||||
*/
|
||||
@Async
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void log(Long userId, String username, String action, String resource,
|
||||
String resourceId, String description, String requestMethod,
|
||||
String requestUri, String ipAddress, String userAgent,
|
||||
Integer status, String errorMessage) {
|
||||
// 1. 创建审计日志实体
|
||||
SysAuditLog auditLog = new SysAuditLog();
|
||||
auditLog.setUserId(userId);
|
||||
auditLog.setUsername(username);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResource(resource);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setDescription(description);
|
||||
auditLog.setRequestMethod(requestMethod);
|
||||
auditLog.setRequestUri(requestUri);
|
||||
auditLog.setIpAddress(ipAddress);
|
||||
auditLog.setUserAgent(userAgent);
|
||||
auditLog.setStatus(status);
|
||||
auditLog.setErrorMessage(errorMessage);
|
||||
|
||||
// 2. 保存到数据库
|
||||
try {
|
||||
auditLogRepository.save(auditLog);
|
||||
} catch (Exception e) {
|
||||
log.error("保存审计日志失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧日志
|
||||
* 步骤:
|
||||
* 1. 计算截止日期(当前时间 - 保留天数)
|
||||
* 2. 删除截止日期之前的日志
|
||||
*
|
||||
* 使用场景:
|
||||
* - 定时任务定期清理(如每天凌晨执行)
|
||||
* - 建议保留最近 90 天的日志
|
||||
*
|
||||
* @param daysToKeep 保留天数
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cleanupOldLogs(int daysToKeep) {
|
||||
// 1. 计算截止日期
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(daysToKeep);
|
||||
|
||||
// 2. 删除旧日志
|
||||
auditLogRepository.deleteByCreatedAtBefore(cutoffDate);
|
||||
|
||||
log.info("已清理 {} 天前的审计日志", daysToKeep);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,103 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.CustomUserDetails;
|
||||
import com.aisi.template.domain.entity.SysRole;
|
||||
import com.aisi.template.domain.entity.SysPermission;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 自定义用户详情服务实现类
|
||||
* 为 Spring Security 提供用户认证和授权信息
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 加载用户信息:根据用户名加载用户及其角色
|
||||
* 2. 构建用户权限:合并角色权限和具体权限
|
||||
* 3. 创建认证对象:返回 CustomUserDetails 对象
|
||||
*
|
||||
* 权限格式:
|
||||
* - 角色权限:ROLE_XXX(如 ROLE_USER, ROLE_ADMIN)
|
||||
* - 具体权限:XXX:YYY(如 user:create, role:read)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* 根据用户名加载用户详情
|
||||
* 步骤:
|
||||
* 1. 查询用户及其角色(使用 JOIN FETCH 避免懒加载问题)
|
||||
* 2. 检查用户是否存在
|
||||
* 3. 检查用户是否被禁用
|
||||
* 4. 构建权限列表(角色 + 权限)
|
||||
* 5. 创建 CustomUserDetails 对象
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 用户详情对象
|
||||
* @throws UsernameNotFoundException 当用户不存在或被禁用时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUsername(username)
|
||||
// 1. 查询用户及其角色
|
||||
User user = userRepository.findByUsernameWithRolesAndPermissions(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
|
||||
|
||||
// 检查用户状态
|
||||
// 2. 检查用户是否被禁用
|
||||
if (!user.isEnabled()) {
|
||||
throw new UsernameNotFoundException("用户已被禁用: " + username);
|
||||
}
|
||||
|
||||
// 3. 获取用户角色
|
||||
Set<SysRole> roles = user.getRoles();
|
||||
Set<String> roleCodes = roles.stream()
|
||||
.map(SysRole::getRoleCode)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 4. 构建权限列表:包含角色权限和具体权限
|
||||
Collection<SimpleGrantedAuthority> authorities = roles.stream()
|
||||
.flatMap(role -> {
|
||||
// 4.1 添加角色权限(ROLE_XXX 格式)
|
||||
List<SimpleGrantedAuthority> roleAuths = List.of(
|
||||
new SimpleGrantedAuthority(role.getRoleCode())
|
||||
);
|
||||
|
||||
// 4.2 添加具体权限(XXX:YYY 格式,如 user:create, role:read)
|
||||
List<SimpleGrantedAuthority> permAuths = role.getPermissions().stream()
|
||||
.map(permission -> new SimpleGrantedAuthority(permission.getPermissionCode()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4.3 合并角色和权限
|
||||
return java.util.stream.Stream.concat(roleAuths.stream(), permAuths.stream());
|
||||
})
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 5. 创建 CustomUserDetails 对象
|
||||
return new CustomUserDetails(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getPassword(),
|
||||
List.of(() -> user.getRole().getAuthority()),
|
||||
authorities,
|
||||
user.isEnabled(),
|
||||
user.getRole().name()
|
||||
roleCodes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,29 +4,80 @@ import com.aisi.template.service.EmailService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 邮件服务实现类
|
||||
* 负责发送各类邮件
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 发送密码重置验证码邮件
|
||||
* 2. 发送注册欢迎邮件(预留)
|
||||
* 3. 发送通知邮件(预留)
|
||||
*
|
||||
* 邮件配置:
|
||||
* - 发件人地址:从配置文件读取(spring.mail.username)
|
||||
* - 验证码有效期:从配置读取(app.password-reset.code-expire-minutes)
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmailServiceImpl implements EmailService {
|
||||
|
||||
/**
|
||||
* Java Mail Sender
|
||||
* Spring 提供的邮件发送工具
|
||||
*/
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
/**
|
||||
* 发件人邮箱地址
|
||||
* 从配置文件读取
|
||||
*/
|
||||
@Value("${spring.mail.username}")
|
||||
private String from;
|
||||
|
||||
/**
|
||||
* 验证码有效期(分钟)
|
||||
* 从配置读取,用于在邮件中提示用户
|
||||
*/
|
||||
@Value("${app.password-reset.code-expire-minutes:10}")
|
||||
private Integer expireMinutes;
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码邮件
|
||||
* 步骤:
|
||||
* 1. 创建简单邮件消息
|
||||
* 2. 设置发件人
|
||||
* 3. 设置收件人
|
||||
* 4. 设置邮件主题
|
||||
* 5. 设置邮件内容(包含验证码和有效期)
|
||||
* 6. 发送邮件
|
||||
*
|
||||
* @param email 收件人邮箱
|
||||
* @param code 6 位数字验证码
|
||||
*/
|
||||
@Override
|
||||
public void sendPasswordResetCode(String email, String code) {
|
||||
// 1. 创建简单邮件消息
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
|
||||
// 2. 设置发件人
|
||||
message.setFrom(from);
|
||||
|
||||
// 3. 设置收件人
|
||||
message.setTo(email);
|
||||
|
||||
// 4. 设置邮件主题
|
||||
message.setSubject("密码找回验证码");
|
||||
|
||||
// 5. 设置邮件内容
|
||||
message.setText("""
|
||||
您正在进行密码找回操作。
|
||||
|
||||
@@ -35,7 +86,9 @@ public class EmailServiceImpl implements EmailService {
|
||||
|
||||
如果这不是您的操作,请忽略此邮件。
|
||||
""".formatted(code, expireMinutes));
|
||||
|
||||
// 6. 发送邮件
|
||||
mailSender.send(message);
|
||||
log.info("已发送密码找回验证码到邮箱: {}", email);
|
||||
log.info("已发送密码找回验证码到邮箱 - email: {}, code: {}", email, code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.LoginAttemptService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 登录尝试服务实现类
|
||||
* 负责处理登录失败计数和账户锁定
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 记录登录失败尝试:计数失败次数,超过阈值则锁定账户
|
||||
* 2. 重置失败计数:登录成功后清零
|
||||
* 3. 检查账户锁定状态:判断账户是否被锁定
|
||||
* 4. 锁定账户:手动或自动锁定账户
|
||||
* 5. 获取剩余锁定时间:计算账户还需多久解锁
|
||||
*
|
||||
* 安全机制:
|
||||
* - 连续失败 5 次锁定账户 30 分钟
|
||||
* - 锁定期间无法登录
|
||||
* - 登录成功后重置失败计数
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoginAttemptServiceImpl implements LoginAttemptService {
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* 最大失败次数
|
||||
* 从配置读取,默认 5 次
|
||||
*/
|
||||
@Value("${app.login.max-attempts:5}")
|
||||
private int maxAttempts;
|
||||
|
||||
/**
|
||||
* 锁定时长(分钟)
|
||||
* 从配置读取,默认 30 分钟
|
||||
*/
|
||||
@Value("${app.login.lock-duration-minutes:30}")
|
||||
private int lockDurationMinutes;
|
||||
|
||||
/**
|
||||
* 记录登录失败尝试
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 失败次数加 1
|
||||
* 3. 检查是否超过最大失败次数
|
||||
* 4. 如果超过,锁定账户并返回 true
|
||||
* 5. 保存更新到数据库
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return true 表示账户应该被锁定
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean recordFailedAttempt(String username) {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 失败次数加 1
|
||||
int failedCount = user.getFailedLoginCount() + 1;
|
||||
user.setFailedLoginCount(failedCount);
|
||||
|
||||
// 3. 检查是否应该锁定账户
|
||||
boolean shouldLock = failedCount >= maxAttempts;
|
||||
if (shouldLock) {
|
||||
// 3.1 锁定账户:计算锁定到期时间
|
||||
LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(lockDurationMinutes);
|
||||
user.setLockedUntil(lockUntil);
|
||||
log.warn("用户因登录失败次数过多被锁定 - username: {}, failedCount: {}, lockUntil: {}",
|
||||
username, failedCount, lockUntil);
|
||||
} else {
|
||||
log.debug("用户登录失败 - username: {}, failedCount: {}/{}",
|
||||
username, failedCount, maxAttempts);
|
||||
}
|
||||
|
||||
// 4. 保存更新
|
||||
userRepository.save(user);
|
||||
return shouldLock;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置登录失败计数
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 重置失败计数为 0
|
||||
* 3. 清除锁定时间
|
||||
* 4. 保存到数据库
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void resetFailedAttempts(String username) {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user != null) {
|
||||
// 2. 重置失败计数为 0
|
||||
user.setFailedLoginCount(0);
|
||||
// 3. 清除锁定时间
|
||||
user.setLockedUntil(null);
|
||||
userRepository.save(user);
|
||||
log.debug("用户登录失败计数已重置 - username: {}", username);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否被锁定
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 调用实体的 isLocked() 方法判断
|
||||
*
|
||||
* 说明:
|
||||
* - 实体的 isLocked() 方法会自动处理过期锁定的判断
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return true 表示账户被锁定
|
||||
*/
|
||||
@Override
|
||||
public boolean isLocked(String username) {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
// 2. 判断是否被锁定(实体的 isLocked() 方法会自动处理过期判断)
|
||||
return user.isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定账户
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 设置锁定时间为当前时间 + 指定分钟数
|
||||
* 3. 保存到数据库
|
||||
*
|
||||
* 使用场景:
|
||||
* - 管理员手动锁定用户
|
||||
* - 自动锁定(由 recordFailedAttempt 触发)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param lockDurationMinutes 锁定时长(分钟)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void lockAccount(String username, int lockDurationMinutes) {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user != null) {
|
||||
// 2. 设置锁定时间
|
||||
LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(lockDurationMinutes);
|
||||
user.setLockedUntil(lockUntil);
|
||||
userRepository.save(user);
|
||||
log.warn("账户已被手动锁定 - username: {}, lockUntil: {}", username, lockUntil);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户剩余锁定时间
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 检查是否有锁定时间
|
||||
* 3. 检查锁定是否已过期
|
||||
* 4. 计算剩余分钟数
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 剩余锁定时间(分钟),未锁定返回 0
|
||||
*/
|
||||
@Override
|
||||
public Long getRemainingLockTimeMinutes(String username) {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null || user.getLockedUntil() == null) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
// 2. 检查锁定是否已过期
|
||||
if (LocalDateTime.now().isAfter(user.getLockedUntil())) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
// 3. 计算剩余分钟数
|
||||
long remainingMinutes = java.time.Duration.between(
|
||||
LocalDateTime.now(),
|
||||
user.getLockedUntil()
|
||||
).toMinutes();
|
||||
|
||||
return Math.max(0, remainingMinutes);
|
||||
}
|
||||
}
|
||||
@@ -25,38 +25,107 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
/**
|
||||
* 密码重置服务实现类
|
||||
* 负责密码重置流程的业务逻辑
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 发送重置验证码:生成验证码并发送到用户邮箱
|
||||
* 2. 验证码重置密码:验证验证码并更新密码
|
||||
*
|
||||
* 安全机制:
|
||||
* - 验证码 6 位数字,10 分钟有效
|
||||
* - 同一邮箱 60 秒内只能请求一次(冷却时间)
|
||||
* - 同一邮箱 1 小时内最多请求 5 次
|
||||
* - 验证码最多尝试 5 次,超过后失效
|
||||
* - 验证码哈希存储(SHA-256),不存储明文
|
||||
* - 新密码使用 BCrypt 加密
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordResetServiceImpl implements PasswordResetService {
|
||||
|
||||
/**
|
||||
* 安全随机数生成器
|
||||
* 用于生成验证码
|
||||
*/
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* 密码重置验证码数据访问接口
|
||||
*/
|
||||
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||
|
||||
/**
|
||||
* 密码编码器
|
||||
* 用于加密新密码
|
||||
*/
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* 邮件服务
|
||||
* 用于发送验证码邮件
|
||||
*/
|
||||
private final EmailService emailService;
|
||||
|
||||
/**
|
||||
* 验证码有效期(分钟),默认 10 分钟
|
||||
*/
|
||||
@Value("${app.password-reset.code-expire-minutes:10}")
|
||||
private Integer expireMinutes;
|
||||
|
||||
/**
|
||||
* 请求冷却时间(秒),默认 60 秒
|
||||
*/
|
||||
@Value("${app.password-reset.request-cooldown-seconds:60}")
|
||||
private Integer cooldownSeconds;
|
||||
|
||||
/**
|
||||
* 验证码最大尝试次数,默认 5 次
|
||||
*/
|
||||
@Value("${app.password-reset.max-attempts:5}")
|
||||
private Integer maxAttempts;
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码
|
||||
* 步骤:
|
||||
* 1. 标准化邮箱(转小写,去除空格)
|
||||
* 2. 检查邮箱是否存在(为防止用户名枚举,无论是否存在都返回成功)
|
||||
* 3. 检查请求冷却时间(防止频繁请求)
|
||||
* 4. 检查 1 小时内请求次数(防止滥用)
|
||||
* 5. 将该邮箱的所有旧验证码标记为已使用
|
||||
* 6. 生成 6 位随机数字验证码
|
||||
* 7. 将验证码哈希后存储到数据库
|
||||
* 8. 发送验证码到用户邮箱
|
||||
*
|
||||
* @param requestDto 重置请求(邮箱)
|
||||
* @return 成功响应
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto) {
|
||||
// 1. 标准化邮箱(转小写,去除首尾空格)
|
||||
String email = requestDto.getEmail().trim().toLowerCase();
|
||||
|
||||
// 2. 检查邮箱是否存在
|
||||
// 注意:为防止用户名枚举攻击,无论邮箱是否存在都返回相同消息
|
||||
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
|
||||
}
|
||||
|
||||
// 3. 检查请求冷却时间(防止频繁请求)
|
||||
Optional<PasswordResetCode> latestCode = passwordResetCodeRepository
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email);
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email);
|
||||
if (latestCode.isPresent()) {
|
||||
LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds);
|
||||
if (nextAllowedAt.isAfter(LocalDateTime.now())) {
|
||||
@@ -65,64 +134,103 @@ public class PasswordResetServiceImpl implements PasswordResetService {
|
||||
}
|
||||
}
|
||||
|
||||
long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(email, LocalDateTime.now().minusHours(1));
|
||||
// 4. 检查 1 小时内请求次数(防止滥用)
|
||||
long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(
|
||||
email, LocalDateTime.now().minusHours(1));
|
||||
if (recentCount >= 5) {
|
||||
return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null);
|
||||
}
|
||||
|
||||
// 5. 将该邮箱的所有旧验证码标记为已使用(避免混淆)
|
||||
List<PasswordResetCode> activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
|
||||
for (PasswordResetCode item : activeCodes) {
|
||||
item.setUsed(true);
|
||||
}
|
||||
passwordResetCodeRepository.saveAll(activeCodes);
|
||||
|
||||
// 6. 生成 6 位随机数字验证码
|
||||
String code = generateCode();
|
||||
|
||||
// 7. 将验证码哈希后存储到数据库
|
||||
PasswordResetCode resetCode = new PasswordResetCode();
|
||||
resetCode.setEmail(email);
|
||||
resetCode.setCodeHash(sha256(code));
|
||||
resetCode.setCodeHash(sha256(code)); // 只存储哈希值,不存储明文
|
||||
resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes));
|
||||
resetCode.setUsed(false);
|
||||
resetCode.setAttemptCount(0);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
|
||||
emailService.sendPasswordResetCode(email, code);
|
||||
// 8. 发送验证码到用户邮箱(容错:邮件服务不可用时不影响验证码生成)
|
||||
try {
|
||||
emailService.sendPasswordResetCode(email, code);
|
||||
log.info("密码重置验证码已发送 - email: {}", email);
|
||||
} catch (Exception e) {
|
||||
log.warn("邮件发送失败,验证码已生成但未发送 - email: {}, error: {}", email, e.getMessage());
|
||||
}
|
||||
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用验证码重置密码
|
||||
* 步骤:
|
||||
* 1. 标准化邮箱
|
||||
* 2. 检查邮箱是否存在
|
||||
* 3. 获取最新的未使用验证码
|
||||
* 4. 验证验证码是否已使用
|
||||
* 5. 验证验证码是否过期
|
||||
* 6. 验证尝试次数是否超限
|
||||
* 7. 验证验证码是否正确
|
||||
* 8. 更新用户密码
|
||||
* 9. 标记验证码为已使用
|
||||
* 10. 将该邮箱的所有其他验证码标记为已使用
|
||||
*
|
||||
* @param confirmDto 确认请求(邮箱、验证码、新密码)
|
||||
* @return 成功响应
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto) {
|
||||
// 1. 标准化邮箱
|
||||
String email = confirmDto.getEmail().trim().toLowerCase();
|
||||
|
||||
// 2. 检查邮箱是否存在
|
||||
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
return RestBean.failure(400, "验证码或邮箱不正确", null);
|
||||
}
|
||||
|
||||
// 3. 获取最新的未使用验证码
|
||||
PasswordResetCode resetCode = passwordResetCodeRepository
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email)
|
||||
.orElse(null);
|
||||
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email)
|
||||
.orElse(null);
|
||||
if (resetCode == null) {
|
||||
return RestBean.failure(400, "请先获取验证码", null);
|
||||
}
|
||||
|
||||
// 4. 验证验证码是否已使用
|
||||
if (Boolean.TRUE.equals(resetCode.getUsed())) {
|
||||
return RestBean.failure(400, "验证码已失效,请重新获取", null);
|
||||
}
|
||||
|
||||
// 5. 验证验证码是否过期
|
||||
if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
return RestBean.failure(400, "验证码已过期,请重新获取", null);
|
||||
}
|
||||
|
||||
// 6. 验证尝试次数是否超限
|
||||
if (resetCode.getAttemptCount() >= maxAttempts) {
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null);
|
||||
}
|
||||
|
||||
// 7. 验证验证码是否正确
|
||||
if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) {
|
||||
// 7.1 增加尝试次数
|
||||
resetCode.setAttemptCount(resetCode.getAttemptCount() + 1);
|
||||
// 7.2 如果尝试次数超限,标记为已使用
|
||||
if (resetCode.getAttemptCount() >= maxAttempts) {
|
||||
resetCode.setUsed(true);
|
||||
}
|
||||
@@ -130,31 +238,58 @@ public class PasswordResetServiceImpl implements PasswordResetService {
|
||||
return RestBean.failure(400, "验证码不正确", null);
|
||||
}
|
||||
|
||||
// 8. 更新用户密码
|
||||
User user = userOptional.get();
|
||||
user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword()));
|
||||
user.setPasswordChangedAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
|
||||
// 9. 标记当前验证码为已使用
|
||||
resetCode.setUsed(true);
|
||||
passwordResetCodeRepository.save(resetCode);
|
||||
|
||||
// 10. 将该邮箱的所有其他验证码标记为已使用
|
||||
List<PasswordResetCode> otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
|
||||
for (PasswordResetCode item : otherCodes) {
|
||||
item.setUsed(true);
|
||||
}
|
||||
passwordResetCodeRepository.saveAll(otherCodes);
|
||||
|
||||
log.info("用户通过邮箱验证码重置密码成功: {}", email);
|
||||
log.info("用户通过邮箱验证码重置密码成功 - email: {}", email);
|
||||
return RestBean.success(RestCode.SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 6 位随机数字验证码
|
||||
* 步骤:
|
||||
* 1. 生成 0 到 999999 之间的随机数
|
||||
* 2. 格式化为 6 位数字(不足补零)
|
||||
*
|
||||
* @return 6 位数字验证码
|
||||
*/
|
||||
private String generateCode() {
|
||||
return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对字符串进行 SHA-256 哈希
|
||||
* 步骤:
|
||||
* 1. 获取 SHA-256 算法实例
|
||||
* 2. 对字符串的字节进行哈希
|
||||
* 3. 将结果转换为十六进制字符串
|
||||
*
|
||||
* @param value 待哈希的字符串
|
||||
* @return 哈希后的十六进制字符串
|
||||
*/
|
||||
private String sha256(String value) {
|
||||
try {
|
||||
// 1. 获取 SHA-256 算法实例
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
// 2. 对字符串进行哈希
|
||||
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 3. 转换为十六进制字符串
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.dto.MenuDto;
|
||||
import com.aisi.template.domain.entity.SysMenu;
|
||||
import com.aisi.template.domain.vo.MenuVo;
|
||||
import com.aisi.template.exception.BusinessException;
|
||||
import com.aisi.template.repository.SysMenuRepository;
|
||||
import com.aisi.template.repository.SysRoleRepository;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.SysMenuService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 系统菜单服务实现类
|
||||
* 负责菜单的管理和树形结构构建
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 菜单管理:创建、更新、删除、查询菜单
|
||||
* 2. 树形结构:构建菜单的层级关系
|
||||
* 3. 用户菜单:根据用户角色获取可访问的菜单
|
||||
*
|
||||
* 菜单类型:
|
||||
* - DIRECTORY(1):目录,用于分组,不对应具体页面
|
||||
* - MENU(2):菜单项,对应具体页面
|
||||
* - BUTTON(3):按钮,页面内的操作按钮
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SysMenuServiceImpl implements SysMenuService {
|
||||
|
||||
/**
|
||||
* 菜单数据访问接口
|
||||
*/
|
||||
private final SysMenuRepository menuRepository;
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* 角色数据访问接口
|
||||
*/
|
||||
private final SysRoleRepository roleRepository;
|
||||
|
||||
/**
|
||||
* 创建菜单
|
||||
* 步骤:
|
||||
* 1. 如果有父菜单,检查父菜单是否存在
|
||||
* 2. 构建菜单实体对象
|
||||
* 3. 保存到数据库
|
||||
*
|
||||
* @param menuDto 菜单数据传输对象
|
||||
* @return 创建的菜单视图对象
|
||||
* @throws BusinessException 当父菜单不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MenuVo create(MenuDto menuDto) {
|
||||
// 1. 检查父菜单是否存在(如果指定了父菜单)
|
||||
if (menuDto.getParentId() != null && menuDto.getParentId() > 0) {
|
||||
if (!menuRepository.existsById(menuDto.getParentId())) {
|
||||
throw new BusinessException("父菜单不存在: " + menuDto.getParentId());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 构建菜单实体
|
||||
SysMenu menu = new SysMenu();
|
||||
menu.setParentId(menuDto.getParentId());
|
||||
menu.setMenuName(menuDto.getMenuName());
|
||||
menu.setMenuType(menuDto.getMenuType());
|
||||
menu.setMenuPath(menuDto.getMenuPath());
|
||||
menu.setComponent(menuDto.getComponent());
|
||||
menu.setIcon(menuDto.getIcon());
|
||||
menu.setSortOrder(menuDto.getSortOrder());
|
||||
menu.setVisible(menuDto.getVisible());
|
||||
menu.setStatus(menuDto.getStatus());
|
||||
menu.setPermissionCode(menuDto.getPermissionCode());
|
||||
|
||||
// 3. 保存到数据库
|
||||
SysMenu savedMenu = menuRepository.save(menu);
|
||||
log.info("菜单创建成功 - id: {}, name: {}", savedMenu.getId(), savedMenu.getMenuName());
|
||||
return convertToVo(savedMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 检查是否将菜单设置为自己的子菜单(循环引用)
|
||||
* 3. 更新菜单信息
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @param menuDto 菜单数据传输对象
|
||||
* @return 更新后的菜单视图对象
|
||||
* @throws BusinessException 当菜单不存在或产生循环引用时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MenuVo update(Long id, MenuDto menuDto) {
|
||||
// 1. 检查菜单是否存在
|
||||
SysMenu menu = menuRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException("菜单不存在: " + id));
|
||||
|
||||
// 2. 检查是否将菜单设置为自己的子菜单
|
||||
if (menuDto.getParentId() != null && menuDto.getParentId().equals(id)) {
|
||||
throw new BusinessException("不能将菜单设置为自己的子菜单");
|
||||
}
|
||||
|
||||
// 3. 更新菜单信息
|
||||
menu.setParentId(menuDto.getParentId());
|
||||
menu.setMenuName(menuDto.getMenuName());
|
||||
menu.setMenuType(menuDto.getMenuType());
|
||||
menu.setMenuPath(menuDto.getMenuPath());
|
||||
menu.setComponent(menuDto.getComponent());
|
||||
menu.setIcon(menuDto.getIcon());
|
||||
menu.setSortOrder(menuDto.getSortOrder());
|
||||
menu.setVisible(menuDto.getVisible());
|
||||
menu.setStatus(menuDto.getStatus());
|
||||
menu.setPermissionCode(menuDto.getPermissionCode());
|
||||
|
||||
// 4. 保存更新
|
||||
SysMenu savedMenu = menuRepository.save(menu);
|
||||
log.info("菜单更新成功 - id: {}, name: {}", savedMenu.getId(), savedMenu.getMenuName());
|
||||
return convertToVo(savedMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
* 步骤:
|
||||
* 1. 检查菜单是否存在
|
||||
* 2. 删除菜单(子菜单会级联删除)
|
||||
*
|
||||
* 注意:
|
||||
* - 删除父菜单会自动删除所有子菜单
|
||||
* - 建议删除前检查是否有子菜单
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @throws BusinessException 当菜单不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
// 1. 检查菜单是否存在
|
||||
if (!menuRepository.existsById(id)) {
|
||||
throw new BusinessException("菜单不存在: " + id);
|
||||
}
|
||||
|
||||
// 2. 删除菜单(子菜单会级联删除)
|
||||
menuRepository.deleteById(id);
|
||||
log.info("菜单删除成功 - id: {}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取菜单
|
||||
* 步骤:
|
||||
* 1. 查询菜单
|
||||
* 2. 转换为视图对象返回
|
||||
*
|
||||
* @param id 菜单ID
|
||||
* @return 菜单视图对象
|
||||
* @throws BusinessException 当菜单不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public MenuVo getById(Long id) {
|
||||
// 1. 查询菜单
|
||||
SysMenu menu = menuRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException("菜单不存在: " + id));
|
||||
|
||||
// 2. 转换为视图对象
|
||||
return convertToVo(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单树
|
||||
* 步骤:
|
||||
* 1. 查询所有启用状态的菜单
|
||||
* 2. 按排序字段排序
|
||||
* 3. 递归构建树形结构
|
||||
*
|
||||
* 树形结构示例:
|
||||
* - 系统管理 (parentId = null)
|
||||
* - 用户管理 (parentId = 1)
|
||||
* - 添加按钮 (parentId = 2)
|
||||
* - 编辑按钮 (parentId = 2)
|
||||
* - 角色管理 (parentId = 1)
|
||||
*
|
||||
* @return 菜单树列表
|
||||
*/
|
||||
@Override
|
||||
public List<MenuVo> getMenuTree() {
|
||||
// 1. 查询所有启用状态的菜单,按排序字段排序
|
||||
List<SysMenu> allMenus = menuRepository.findByStatusOrderBySortOrder(1);
|
||||
|
||||
// 2. 构建树形结构(根节点的 parentId 为 0)
|
||||
return buildMenuTree(allMenus, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户菜单
|
||||
* 步骤:
|
||||
* 1. 查询用户及其角色
|
||||
* 2. 根据角色查询可访问的菜单
|
||||
* 3. 过滤掉按钮类型(只返回目录和菜单)
|
||||
* 4. 构建树形结构
|
||||
*
|
||||
* 说明:
|
||||
* - 只返回目录(DIRECTORY)和菜单(MENU),不返回按钮(BUTTON)
|
||||
* - 按钮权限通过前端的权限指令控制
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户可访问的菜单树
|
||||
* @throws BusinessException 当用户不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public List<MenuVo> getUserMenus(Long userId) {
|
||||
// 1. 查询用户及其角色
|
||||
var user = userRepository.findByIdWithRoles(userId)
|
||||
.orElseThrow(() -> new BusinessException("用户不存在: " + userId));
|
||||
|
||||
// 2. 提取角色ID列表
|
||||
List<Long> roleIds = user.getRoles().stream()
|
||||
.map(role -> role.getId())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 3. 如果用户没有角色,返回空列表
|
||||
if (roleIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 4. 查询角色可访问的菜单
|
||||
// 4.1 查询目录类型(menuType = 1)
|
||||
// 4.2 查询菜单类型(menuType = 2)
|
||||
// 注意:不查询按钮类型(menuType = 3)
|
||||
Set<SysMenu> menus = new java.util.HashSet<>(menuRepository.findByRoleIdsAndMenuType(roleIds, 2)); // 菜单
|
||||
menus.addAll(menuRepository.findByRoleIdsAndMenuType(roleIds, 1)); // 目录
|
||||
|
||||
// 5. 构建树形结构
|
||||
return buildMenuTree(new ArrayList<>(menus), 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据父ID获取子菜单
|
||||
* 步骤:
|
||||
* 1. 查询指定父ID下的所有子菜单
|
||||
* 2. 按排序字段排序
|
||||
* 3. 转换为视图对象列表
|
||||
*
|
||||
* @param parentId 父菜单ID(null 表示查询根菜单)
|
||||
* @return 子菜单列表
|
||||
*/
|
||||
@Override
|
||||
public List<MenuVo> getByParentId(Long parentId) {
|
||||
// 1. 查询子菜单,按排序字段排序
|
||||
List<SysMenu> menus = menuRepository.findByParentIdOrderBySortOrder(parentId);
|
||||
|
||||
// 2. 转换为视图对象列表
|
||||
return menus.stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归构建菜单树
|
||||
* 步骤:
|
||||
* 1. 遍历所有菜单
|
||||
* 2. 找到当前父ID的子菜单
|
||||
* 3. 递归查找子菜单的子菜单
|
||||
* 4. 构建树形结构
|
||||
*
|
||||
* @param menus 所有菜单列表
|
||||
* @param parentId 父菜单ID
|
||||
* @return 子菜单树列表
|
||||
*/
|
||||
private List<MenuVo> buildMenuTree(List<SysMenu> menus, Long parentId) {
|
||||
List<MenuVo> result = new ArrayList<>();
|
||||
|
||||
// 1. 遍历所有菜单,找到当前父ID的子菜单
|
||||
for (SysMenu menu : menus) {
|
||||
// 处理 parentId 为 null 的情况(根节点)
|
||||
Long menuParentId = menu.getParentId() == null ? 0L : menu.getParentId();
|
||||
|
||||
if (menuParentId.equals(parentId)) {
|
||||
// 2. 转换为视图对象
|
||||
MenuVo vo = convertToVo(menu);
|
||||
|
||||
// 3. 递归查找子菜单
|
||||
vo.setChildren(buildMenuTree(menus, menu.getId()));
|
||||
|
||||
// 4. 添加到结果列表
|
||||
result.add(vo);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将菜单实体转换为视图对象
|
||||
* 步骤:
|
||||
* 1. 复制基本信息
|
||||
* 2. 复制菜单类型和路径
|
||||
* 3. 复制显示和状态信息
|
||||
* 4. 复制时间信息
|
||||
*
|
||||
* @param menu 菜单实体
|
||||
* @return 菜单视图对象
|
||||
*/
|
||||
private MenuVo convertToVo(SysMenu menu) {
|
||||
MenuVo vo = new MenuVo();
|
||||
// 1. 复制基本信息
|
||||
vo.setId(menu.getId());
|
||||
vo.setParentId(menu.getParentId());
|
||||
vo.setMenuName(menu.getMenuName());
|
||||
|
||||
// 2. 复制菜单类型和路径
|
||||
vo.setMenuType(menu.getMenuType());
|
||||
vo.setMenuPath(menu.getMenuPath());
|
||||
vo.setComponent(menu.getComponent());
|
||||
vo.setIcon(menu.getIcon());
|
||||
|
||||
// 3. 复制排序和状态信息
|
||||
vo.setSortOrder(menu.getSortOrder());
|
||||
vo.setVisible(menu.getVisible());
|
||||
vo.setStatus(menu.getStatus());
|
||||
vo.setPermissionCode(menu.getPermissionCode());
|
||||
|
||||
// 4. 复制时间信息
|
||||
vo.setCreatedAt(menu.getCreatedAt());
|
||||
vo.setUpdatedAt(menu.getUpdatedAt());
|
||||
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.entity.SysPermission;
|
||||
import com.aisi.template.domain.vo.PermissionVo;
|
||||
import com.aisi.template.exception.BusinessException;
|
||||
import com.aisi.template.repository.SysPermissionRepository;
|
||||
import com.aisi.template.service.SysPermissionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 系统权限服务实现类
|
||||
* 负责权限的查询和管理
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 权限查询:获取所有权限、按ID查询、按资源查询、按操作查询
|
||||
* 2. 权限转换:将权限实体转换为视图对象
|
||||
*
|
||||
* 设计说明:
|
||||
* - 权限是系统预定义的,通过数据库迁移脚本初始化
|
||||
* - 不支持动态创建/删除权限(保证系统稳定性)
|
||||
* - 权限通过角色分配给用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SysPermissionServiceImpl implements SysPermissionService {
|
||||
|
||||
/**
|
||||
* 权限数据访问接口
|
||||
*/
|
||||
private final SysPermissionRepository permissionRepository;
|
||||
|
||||
/**
|
||||
* 获取所有权限
|
||||
* 步骤:
|
||||
* 1. 查询所有启用状态的权限
|
||||
* 2. 转换为视图对象列表
|
||||
* 3. 按资源和操作排序返回
|
||||
*
|
||||
* @return 所有权限视图对象列表
|
||||
*/
|
||||
@Override
|
||||
public List<PermissionVo> getAllPermissions() {
|
||||
// 1. 查询所有启用状态的权限(status = 1)
|
||||
List<SysPermission> permissions = permissionRepository.findByStatus(1);
|
||||
|
||||
// 2. 转换为视图对象列表
|
||||
return permissions.stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取权限
|
||||
* 步骤:
|
||||
* 1. 根据ID查询权限
|
||||
* 2. 如果不存在,抛出异常
|
||||
* 3. 转换为视图对象返回
|
||||
*
|
||||
* @param id 权限ID
|
||||
* @return 权限视图对象
|
||||
* @throws BusinessException 当权限不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public PermissionVo getById(Long id) {
|
||||
// 1. 查询权限
|
||||
SysPermission permission = permissionRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException("权限不存在: " + id));
|
||||
|
||||
// 2. 转换为视图对象
|
||||
return convertToVo(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据资源获取权限
|
||||
* 步骤:
|
||||
* 1. 按资源名称查询权限
|
||||
* 2. 转换为视图对象列表
|
||||
*
|
||||
* 示例:
|
||||
* - resource="user" 返回 user:read, user:write, user:delete
|
||||
*
|
||||
* @param resource 资源名称(如:user, role, menu)
|
||||
* @return 该资源的所有权限
|
||||
*/
|
||||
@Override
|
||||
public List<PermissionVo> getByResource(String resource) {
|
||||
// 1. 按资源查询权限
|
||||
List<SysPermission> permissions = permissionRepository.findByResource(resource);
|
||||
|
||||
// 2. 转换为视图对象列表
|
||||
return permissions.stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据操作类型获取权限
|
||||
* 步骤:
|
||||
* 1. 按操作类型查询权限
|
||||
* 2. 转换为视图对象列表
|
||||
*
|
||||
* 示例:
|
||||
* - action="read" 返回 user:read, role:read, menu:read 等
|
||||
*
|
||||
* @param action 操作类型(read, write, delete)
|
||||
* @return 拥有该操作的所有权限
|
||||
*/
|
||||
@Override
|
||||
public List<PermissionVo> getByAction(String action) {
|
||||
// 1. 按操作类型查询权限
|
||||
List<SysPermission> permissions = permissionRepository.findByAction(action);
|
||||
|
||||
// 2. 转换为视图对象列表
|
||||
return permissions.stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限实体转换为视图对象
|
||||
* 步骤:
|
||||
* 1. 复制基本信息(ID、编码、名称)
|
||||
* 2. 复制资源操作信息
|
||||
* 3. 复制描述和时间信息
|
||||
*
|
||||
* @param permission 权限实体
|
||||
* @return 权限视图对象
|
||||
*/
|
||||
private PermissionVo convertToVo(SysPermission permission) {
|
||||
PermissionVo vo = new PermissionVo();
|
||||
// 1. 复制基本信息
|
||||
vo.setId(permission.getId());
|
||||
vo.setPermissionCode(permission.getPermissionCode());
|
||||
vo.setPermissionName(permission.getPermissionName());
|
||||
|
||||
// 2. 复制资源操作信息
|
||||
vo.setResource(permission.getResource());
|
||||
vo.setAction(permission.getAction());
|
||||
|
||||
// 3. 复制其他信息
|
||||
vo.setDescription(permission.getDescription());
|
||||
vo.setStatus(permission.getStatus());
|
||||
vo.setCreatedAt(permission.getCreatedAt());
|
||||
vo.setUpdatedAt(permission.getUpdatedAt());
|
||||
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.dto.RoleDto;
|
||||
import com.aisi.template.domain.dto.RoleQueryDto;
|
||||
import com.aisi.template.domain.entity.SysPermission;
|
||||
import com.aisi.template.domain.entity.SysRole;
|
||||
import com.aisi.template.domain.dto.PageResult;
|
||||
import com.aisi.template.domain.vo.PermissionVo;
|
||||
import com.aisi.template.domain.vo.RoleVo;
|
||||
import com.aisi.template.exception.BusinessException;
|
||||
import com.aisi.template.repository.SysPermissionRepository;
|
||||
import com.aisi.template.repository.SysRoleRepository;
|
||||
import com.aisi.template.service.SysRoleService;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 系统角色服务实现类
|
||||
* 提供角色的 CRUD 操作、权限分配、查询等功能
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 角色基本操作:创建、更新、删除、查询
|
||||
* 2. 权限管理:为角色分配权限、获取角色权限
|
||||
* 3. 分页查询:支持条件查询和分页
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SysRoleServiceImpl implements SysRoleService {
|
||||
|
||||
/**
|
||||
* 角色数据访问接口
|
||||
* 用于角色的数据库操作
|
||||
*/
|
||||
private final SysRoleRepository roleRepository;
|
||||
|
||||
/**
|
||||
* 权限数据访问接口
|
||||
* 用于权限的数据库操作
|
||||
*/
|
||||
private final SysPermissionRepository permissionRepository;
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* 步骤:
|
||||
* 1. 校验角色编码是否已存在
|
||||
* 2. 构建角色实体对象
|
||||
* 3. 保存到数据库
|
||||
*
|
||||
* @param roleDto 角色数据传输对象
|
||||
* @return 创建的角色视图对象
|
||||
* @throws BusinessException 当角色编码已存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RoleVo create(RoleDto roleDto) {
|
||||
// 1. 检查角色编码是否已存在
|
||||
roleRepository.findByRoleCode(roleDto.getRoleCode()).ifPresent(role -> {
|
||||
throw new BusinessException("角色编码已存在: " + role.getRoleCode());
|
||||
});
|
||||
|
||||
// 2. 构建角色实体
|
||||
SysRole role = new SysRole();
|
||||
role.setRoleCode(roleDto.getRoleCode());
|
||||
role.setRoleName(roleDto.getRoleName());
|
||||
role.setDescription(roleDto.getDescription());
|
||||
role.setSortOrder(roleDto.getSortOrder());
|
||||
role.setStatus(roleDto.getStatus());
|
||||
|
||||
// 3. 保存角色到数据库
|
||||
SysRole savedRole = roleRepository.save(role);
|
||||
return convertToVo(savedRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 如果修改了角色编码,检查新编码是否已被使用
|
||||
* 3. 更新角色信息
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @param roleDto 角色数据传输对象
|
||||
* @return 更新后的角色视图对象
|
||||
* @throws BusinessException 当角色不存在或编码冲突时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RoleVo update(Long id, RoleDto roleDto) {
|
||||
// 1. 查询角色是否存在
|
||||
SysRole role = roleRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException("角色不存在: " + id));
|
||||
|
||||
// 2. 如果修改了角色编码,检查新编码是否已被使用
|
||||
if (!role.getRoleCode().equals(roleDto.getRoleCode())) {
|
||||
roleRepository.findByRoleCode(roleDto.getRoleCode()).ifPresent(r -> {
|
||||
throw new BusinessException("角色编码已存在: " + r.getRoleCode());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 更新角色信息
|
||||
role.setRoleCode(roleDto.getRoleCode());
|
||||
role.setRoleName(roleDto.getRoleName());
|
||||
role.setDescription(roleDto.getDescription());
|
||||
role.setSortOrder(roleDto.getSortOrder());
|
||||
role.setStatus(roleDto.getStatus());
|
||||
|
||||
// 4. 保存更新
|
||||
SysRole savedRole = roleRepository.save(role);
|
||||
return convertToVo(savedRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* 步骤:
|
||||
* 1. 检查角色是否存在
|
||||
* 2. 删除角色(关联的权限关系会自动级联删除)
|
||||
*
|
||||
* 注意:如果角色已分配给用户,需要先解除关联
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @throws BusinessException 当角色不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
// 1. 检查角色是否存在
|
||||
if (!roleRepository.existsById(id)) {
|
||||
throw new BusinessException("角色不存在: " + id);
|
||||
}
|
||||
|
||||
// 2. 删除角色
|
||||
roleRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取角色
|
||||
* 步骤:
|
||||
* 1. 查询角色基本信息
|
||||
* 2. 使用 JOIN FETCH 一次性加载关联的权限(避免 N+1 问题)
|
||||
* 3. 转换为视图对象返回
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @return 角色视图对象
|
||||
* @throws BusinessException 当角色不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public RoleVo getById(Long id) {
|
||||
// 1. 查询角色(使用 JOIN FETCH 避免懒加载问题)
|
||||
SysRole role = roleRepository.findByIdWithPermissions(id)
|
||||
.orElseThrow(() -> new BusinessException("角色不存在: " + id));
|
||||
|
||||
// 2. 转换为视图对象
|
||||
return convertToVo(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有角色
|
||||
* 步骤:
|
||||
* 1. 查询所有角色
|
||||
* 2. 使用 JOIN FETCH 预加载权限
|
||||
* 3. 转换为视图对象列表
|
||||
*
|
||||
* @return 角色视图对象列表
|
||||
*/
|
||||
@Override
|
||||
public List<RoleVo> getAllRoles() {
|
||||
// 1. 查询所有角色(使用 JOIN FETCH 避免懒加载问题)
|
||||
List<SysRole> roles = roleRepository.findAllWithPermissions();
|
||||
|
||||
// 2. 转换为视图对象列表
|
||||
return roles.stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询角色
|
||||
* 步骤:
|
||||
* 1. 构建动态查询条件(支持角色编码、名称、状态)
|
||||
* 2. 执行分页查询
|
||||
* 3. 转换结果为视图对象
|
||||
*
|
||||
* @param queryDto 查询条件对象
|
||||
* @param page 页码(从 0 开始)
|
||||
* @param size 每页大小
|
||||
* @return 分页结果
|
||||
*/
|
||||
@Override
|
||||
public PageResult<RoleVo> queryRoles(RoleQueryDto queryDto, int page, int size) {
|
||||
// 1. 构建动态查询条件
|
||||
Specification<SysRole> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// 1.1 角色编码模糊查询
|
||||
if (queryDto.getRoleCode() != null && !queryDto.getRoleCode().isEmpty()) {
|
||||
predicates.add(cb.like(root.get("roleCode"), "%" + queryDto.getRoleCode() + "%"));
|
||||
}
|
||||
|
||||
// 1.2 角色名称模糊查询
|
||||
if (queryDto.getRoleName() != null && !queryDto.getRoleName().isEmpty()) {
|
||||
predicates.add(cb.like(root.get("roleName"), "%" + queryDto.getRoleName() + "%"));
|
||||
}
|
||||
|
||||
// 1.3 状态精确查询
|
||||
if (queryDto.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), queryDto.getStatus()));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
// 2. 执行分页查询
|
||||
Page<SysRole> rolePage = roleRepository.findAll(spec, PageRequest.of(page, size));
|
||||
|
||||
// 3. 转换为分页结果
|
||||
return PageResult.of(
|
||||
rolePage.getContent().stream().map(this::convertToVo).collect(Collectors.toList()),
|
||||
rolePage.getTotalElements(),
|
||||
rolePage.getNumber(),
|
||||
rolePage.getSize()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为角色分配权限
|
||||
* 步骤:
|
||||
* 1. 查询角色是否存在
|
||||
* 2. 根据权限ID列表查询所有权限
|
||||
* 3. 清空角色原有的权限
|
||||
* 4. 添加新的权限
|
||||
* 5. 保存更新
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @param permissionIds 权限ID列表
|
||||
* @throws BusinessException 当角色或权限不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void assignPermissions(Long roleId, List<Long> permissionIds) {
|
||||
// 1. 检查角色是否存在
|
||||
SysRole role = roleRepository.findById(roleId)
|
||||
.orElseThrow(() -> new BusinessException("角色不存在: " + roleId));
|
||||
|
||||
// 2. 查询所有权限(如果权限不存在会抛出异常)
|
||||
Set<SysPermission> permissions = permissionIds.stream()
|
||||
.map(permissionId -> permissionRepository.findById(permissionId)
|
||||
.orElseThrow(() -> new BusinessException("权限不存在: " + permissionId)))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 3. 清空原有权限并添加新权限
|
||||
role.getPermissions().clear();
|
||||
role.getPermissions().addAll(permissions);
|
||||
|
||||
// 4. 保存更新
|
||||
roleRepository.save(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色的权限ID列表
|
||||
* 步骤:
|
||||
* 1. 查询角色(使用 JOIN FETCH 加载权限)
|
||||
* 2. 提取所有权限的ID
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 权限ID列表
|
||||
* @throws BusinessException 当角色不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public List<Long> getRolePermissionIds(Long roleId) {
|
||||
// 1. 查询角色及权限
|
||||
SysRole role = roleRepository.findByIdWithPermissions(roleId)
|
||||
.orElseThrow(() -> new BusinessException("角色不存在: " + roleId));
|
||||
|
||||
// 2. 提取权限ID列表
|
||||
return role.getPermissions().stream()
|
||||
.map(SysPermission::getId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将角色实体转换为视图对象
|
||||
* 步骤:
|
||||
* 1. 复制基本信息
|
||||
* 2. 转换权限列表
|
||||
*
|
||||
* @param role 角色实体
|
||||
* @return 角色视图对象
|
||||
*/
|
||||
private RoleVo convertToVo(SysRole role) {
|
||||
RoleVo vo = new RoleVo();
|
||||
vo.setId(role.getId());
|
||||
vo.setRoleCode(role.getRoleCode());
|
||||
vo.setRoleName(role.getRoleName());
|
||||
vo.setDescription(role.getDescription());
|
||||
vo.setSortOrder(role.getSortOrder());
|
||||
vo.setStatus(role.getStatus());
|
||||
vo.setCreatedAt(role.getCreatedAt());
|
||||
vo.setUpdatedAt(role.getUpdatedAt());
|
||||
|
||||
// 1. 如果有权限,转换为视图对象
|
||||
if (role.getPermissions() != null && !role.getPermissions().isEmpty()) {
|
||||
Set<PermissionVo> permissionVos = role.getPermissions().stream()
|
||||
.map(this::convertToPermissionVo)
|
||||
.collect(Collectors.toSet());
|
||||
vo.setPermissions(permissionVos);
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限实体转换为视图对象
|
||||
*
|
||||
* @param permission 权限实体
|
||||
* @return 权限视图对象
|
||||
*/
|
||||
private PermissionVo convertToPermissionVo(SysPermission permission) {
|
||||
PermissionVo vo = new PermissionVo();
|
||||
vo.setId(permission.getId());
|
||||
vo.setPermissionCode(permission.getPermissionCode());
|
||||
vo.setPermissionName(permission.getPermissionName());
|
||||
vo.setResource(permission.getResource());
|
||||
vo.setAction(permission.getAction());
|
||||
vo.setDescription(permission.getDescription());
|
||||
vo.setStatus(permission.getStatus());
|
||||
vo.setCreatedAt(permission.getCreatedAt());
|
||||
vo.setUpdatedAt(permission.getUpdatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.aisi.template.service.impl;
|
||||
|
||||
import com.aisi.template.domain.entity.RefreshToken;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.domain.vo.LoginResponseVo;
|
||||
import com.aisi.template.exception.BusinessException;
|
||||
import com.aisi.template.repository.RefreshTokenRepository;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.TokenService;
|
||||
import com.aisi.template.utils.JwtUtil;
|
||||
import com.aisi.template.utils.RedisUtils;
|
||||
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.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Token 服务实现类
|
||||
* 负责 Refresh Token 的生成、验证、刷新和黑名单管理
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. Refresh Token 管理:生成、存储、验证、撤销
|
||||
* 2. Token 黑名单:将失效的 JWT 加入 Redis 黑名单
|
||||
* 3. Token 刷新:使用 Refresh Token 获取新的 Access Token
|
||||
* 4. 清理任务:清理过期的 Token 和黑名单记录
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TokenServiceImpl implements TokenService {
|
||||
|
||||
/**
|
||||
* Refresh Token 数据访问接口
|
||||
*/
|
||||
private final RefreshTokenRepository refreshTokenRepository;
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
*/
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* Redis 工具类
|
||||
*/
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
/**
|
||||
* Refresh Token 过期时间(秒),从配置读取,默认 7 天
|
||||
*/
|
||||
@Value("${jwt.refresh-token-expiration:604800}")
|
||||
private long refreshTokenExpirationSeconds;
|
||||
|
||||
/**
|
||||
* Access Token 过期时间(秒),从配置读取,默认 1 小时
|
||||
*/
|
||||
@Value("${jwt.access-token-expiration:3600}")
|
||||
private long accessTokenExpirationSeconds;
|
||||
|
||||
/**
|
||||
* Token 黑名单 Redis 键前缀
|
||||
*/
|
||||
private static final String BLACKLIST_KEY_PREFIX = "token:blacklist:";
|
||||
|
||||
/**
|
||||
* Refresh Token Redis 键前缀
|
||||
*/
|
||||
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
|
||||
|
||||
/**
|
||||
* 创建 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 生成随机 UUID 作为 Token
|
||||
* 2. 对 Token 进行 SHA-256 哈希(数据库只存储哈希值)
|
||||
* 3. 计算 Token 过期时间
|
||||
* 4. 保存到数据库
|
||||
* 5. 同时在 Redis 中缓存一份(用于快速查找)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param deviceInfo 设备信息(如:iPhone 14 Pro)
|
||||
* @param ipAddress IP地址
|
||||
* @return Refresh Token 原始值(未哈希)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String createRefreshToken(Long userId, String deviceInfo, String ipAddress) {
|
||||
// 1. 生成随机的 Refresh Token
|
||||
String refreshToken = UUID.randomUUID().toString().replace("-", "");
|
||||
String tokenHash = hashToken(refreshToken);
|
||||
|
||||
// 2. 计算过期时间
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(refreshTokenExpirationSeconds);
|
||||
|
||||
// 3. 创建 Refresh Token 实体
|
||||
RefreshToken tokenEntity = new RefreshToken();
|
||||
tokenEntity.setUserId(userId);
|
||||
tokenEntity.setTokenHash(tokenHash);
|
||||
tokenEntity.setDeviceInfo(deviceInfo);
|
||||
tokenEntity.setIpAddress(ipAddress);
|
||||
tokenEntity.setExpiresAt(expiresAt);
|
||||
tokenEntity.setRevoked(false);
|
||||
|
||||
// 4. 保存到数据库
|
||||
refreshTokenRepository.save(tokenEntity);
|
||||
|
||||
// 5. 在 Redis 中缓存一份,过期时间与 Refresh Token 保持一致
|
||||
// 作用:快速验证 Refresh Token,减少数据库查询
|
||||
redisUtils.set(REFRESH_TOKEN_PREFIX + tokenHash, String.valueOf(userId),
|
||||
refreshTokenExpirationSeconds, TimeUnit.SECONDS);
|
||||
|
||||
log.info("Refresh Token 创建成功 - userId: {}, deviceInfo: {}", userId, deviceInfo);
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Refresh Token 刷新 Access Token
|
||||
* 步骤:
|
||||
* 1. 对 Refresh Token 进行哈希
|
||||
* 2. 先从 Redis 查找,找不到再查数据库
|
||||
* 3. 验证 Token 是否有效(未过期、未撤销)
|
||||
* 4. 获取用户信息并验证用户状态
|
||||
* 5. 生成新的 Access Token
|
||||
* 6. 生成新的 Refresh Token(Token 轮换机制)
|
||||
* 7. 撤销旧的 Refresh Token
|
||||
*
|
||||
* @param refreshToken Refresh Token 原始值
|
||||
* @return 登录响应(包含新的 Access Token 和 Refresh Token)
|
||||
* @throws BusinessException 当 Token 无效或用户状态异常时抛出异常
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LoginResponseVo refreshToken(String refreshToken) {
|
||||
// 1. 计算 Token 哈希值
|
||||
String tokenHash = hashToken(refreshToken);
|
||||
|
||||
// 2. 先从 Redis 查找(快速路径)
|
||||
String cachedUserId = redisUtils.get(REFRESH_TOKEN_PREFIX + tokenHash, String.class);
|
||||
if (cachedUserId == null) {
|
||||
// 2.1 Redis 没找到,查数据库(慢速路径)
|
||||
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
|
||||
.orElse(null);
|
||||
if (tokenEntity == null) {
|
||||
throw new BusinessException("Refresh Token 无效");
|
||||
}
|
||||
if (!tokenEntity.isValid()) {
|
||||
throw new BusinessException("Refresh Token 已过期或已撤销");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取 Token 实体(从数据库)
|
||||
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
|
||||
.orElseThrow(() -> new BusinessException("Refresh Token 无效"));
|
||||
|
||||
// 4. 验证 Token 是否有效
|
||||
if (!tokenEntity.isValid()) {
|
||||
throw new BusinessException("Refresh Token 已过期或已撤销");
|
||||
}
|
||||
|
||||
// 5. 获取用户信息
|
||||
User user = userRepository.findById(tokenEntity.getUserId())
|
||||
.orElseThrow(() -> new BusinessException("用户不存在"));
|
||||
|
||||
// 6. 检查用户状态
|
||||
if (!user.isEnabled()) {
|
||||
throw new BusinessException("用户账户已被禁用");
|
||||
}
|
||||
|
||||
// 7. 生成新的 Access Token
|
||||
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
|
||||
// 8. 生成新的 Refresh Token(Token 轮换)
|
||||
// Token 轮换:每次刷新都生成新的 Refresh Token,旧 Token 失效
|
||||
// 好处:提高安全性,一旦 Token 泄露,使用后即失效
|
||||
revokeRefreshToken(tokenHash);
|
||||
String newRefreshToken = createRefreshToken(
|
||||
user.getId(),
|
||||
tokenEntity.getDeviceInfo(),
|
||||
tokenEntity.getIpAddress()
|
||||
);
|
||||
|
||||
// 9. 构建响应
|
||||
LoginResponseVo response = LoginResponseVo.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(newRefreshToken)
|
||||
.expiresIn(accessTokenExpirationSeconds)
|
||||
.userInfo(LoginResponseVo.UserInfoVo.builder()
|
||||
.id(user.getId())
|
||||
.username(user.getUsername())
|
||||
.email(user.getEmail())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
log.info("Token 刷新成功 - userId: {}", user.getId());
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 查找 Token 实体
|
||||
* 2. 标记为已撤销
|
||||
* 3. 记录撤销时间
|
||||
* 4. 删除 Redis 缓存
|
||||
*
|
||||
* @param tokenHash Token 哈希值
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revokeRefreshToken(String tokenHash) {
|
||||
// 1. 查找 Token 实体
|
||||
RefreshToken token = refreshTokenRepository.findByTokenHash(tokenHash)
|
||||
.orElse(null);
|
||||
if (token != null) {
|
||||
// 2. 标记为已撤销
|
||||
token.setRevoked(true);
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
refreshTokenRepository.save(token);
|
||||
|
||||
// 3. 删除 Redis 缓存
|
||||
redisUtils.delete(REFRESH_TOKEN_PREFIX + tokenHash);
|
||||
|
||||
log.info("Refresh Token 已撤销 - tokenHash: {}", tokenHash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销用户的所有 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 将用户所有有效 Token 标记为已撤销
|
||||
* 2. 删除对应的 Redis 缓存
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户修改密码后,撤销所有 Token
|
||||
* - 用户登出所有设备
|
||||
* - 用户账户被禁用
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revokeAllUserTokens(Long userId) {
|
||||
// 1. 查找用户所有有效 Token,用于删除 Redis 缓存
|
||||
var tokens = refreshTokenRepository.findValidTokensByUserId(userId, LocalDateTime.now());
|
||||
|
||||
// 2. 撤销数据库中所有 Token
|
||||
refreshTokenRepository.revokeAllUserTokens(userId, LocalDateTime.now());
|
||||
|
||||
// 3. 删除 Redis 缓存
|
||||
tokens.forEach(token -> redisUtils.delete(REFRESH_TOKEN_PREFIX + token.getTokenHash()));
|
||||
|
||||
log.info("用户所有 Refresh Token 已撤销 - userId: {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否在黑名单中
|
||||
* 步骤:
|
||||
* 1. 构建 Redis 黑名单键
|
||||
* 2. 检查键是否存在
|
||||
*
|
||||
* @param jti JWT ID(JWT 唯一标识)
|
||||
* @return 是否在黑名单中
|
||||
*/
|
||||
@Override
|
||||
public boolean isTokenBlacklisted(String jti) {
|
||||
String key = BLACKLIST_KEY_PREFIX + jti;
|
||||
return Boolean.TRUE.equals(redisUtils.hasKey(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Token 加入黑名单
|
||||
* 步骤:
|
||||
* 1. 构建 Redis 黑名单键
|
||||
* 2. 设置黑名单记录,过期时间与 Token 剩余有效期一致
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登出时,将当前 Token 加入黑名单
|
||||
* - Token 刷新时,将旧 Token 加入黑名单
|
||||
*
|
||||
* @param jti JWT ID
|
||||
* @param expirationSeconds 过期时间(秒)
|
||||
*/
|
||||
@Override
|
||||
public void addTokenToBlacklist(String jti, long expirationSeconds) {
|
||||
String key = BLACKLIST_KEY_PREFIX + jti;
|
||||
// 设置黑名单记录,过期时间与 Token 剩余有效期一致
|
||||
// 作用:自动清理过期的黑名单记录,避免 Redis 内存溢出
|
||||
redisUtils.set(key, "1", expirationSeconds, TimeUnit.SECONDS);
|
||||
log.info("Token 已加入黑名单 - jti: {}, expire: {}s", jti, expirationSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的 Refresh Token
|
||||
* 步骤:
|
||||
* 1. 查找所有过期的 Token
|
||||
* 2. 批量删除
|
||||
*
|
||||
* 建议通过定时任务定期执行(如每天凌晨)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cleanupExpiredTokens() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
refreshTokenRepository.deleteByExpiresAtBefore(now);
|
||||
log.info("过期 Refresh Token 清理完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的黑名单记录
|
||||
* 说明:
|
||||
* Redis 的黑名单记录设置了过期时间,会自动删除
|
||||
* 此方法仅作为日志记录,实际无需执行
|
||||
*/
|
||||
@Override
|
||||
public void cleanupExpiredBlacklistEntries() {
|
||||
log.debug("黑名单记录由 Redis 自动过期清理");
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 Token 进行 SHA-256 哈希
|
||||
* 步骤:
|
||||
* 1. 获取 SHA-256 算法实例
|
||||
* 2. 对 Token 字节进行哈希
|
||||
* 3. 转换为十六进制字符串
|
||||
*
|
||||
* 安全说明:
|
||||
* - 数据库只存储哈希值,不存储原始 Token
|
||||
* - 即使数据库泄露,攻击者也无法获得有效的 Refresh Token
|
||||
*
|
||||
* @param token 原始 Token
|
||||
* @return 哈希后的 Token(十六进制字符串)
|
||||
*/
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
// 1. 获取 SHA-256 算法实例
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
// 2. 对 Token 进行哈希
|
||||
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 3. 转换为十六进制字符串
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// 4. SHA-256 是 Java 标准库必须支持的算法,理论上不会抛出此异常
|
||||
throw new RuntimeException("SHA-256 算法不可用", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,18 @@ import com.aisi.template.domain.dto.UserDto;
|
||||
import com.aisi.template.domain.dto.UserQueryDto;
|
||||
import com.aisi.template.domain.dto.UserRoleUpdateDto;
|
||||
import com.aisi.template.domain.dto.UserStatusUpdateDto;
|
||||
import com.aisi.template.domain.entity.SysRole;
|
||||
import com.aisi.template.domain.entity.User;
|
||||
import com.aisi.template.domain.enums.Role;
|
||||
import com.aisi.template.domain.vo.LoginResponseVo;
|
||||
import com.aisi.template.domain.vo.UserVo;
|
||||
import com.aisi.template.repository.SysRoleRepository;
|
||||
import com.aisi.template.repository.UserRepository;
|
||||
import com.aisi.template.service.TokenService;
|
||||
import com.aisi.template.service.UserService;
|
||||
import com.aisi.template.utils.JwtUtil;
|
||||
import com.aisi.template.service.LoginAttemptService;
|
||||
import com.aisi.template.utils.SecurityUtils;
|
||||
import com.aisi.template.mq.producer.UserMessageProducer;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -24,108 +29,303 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户服务实现类
|
||||
* 负责用户相关的业务逻辑处理
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 用户注册:校验、创建、分配默认角色、生成 Token
|
||||
* 2. 用户登录:验证、状态检查、失败计数、生成 Token
|
||||
* 3. 用户信息:获取当前用户信息
|
||||
* 4. 用户管理:分页查询、状态更新、角色分配
|
||||
*
|
||||
* 事务说明:
|
||||
* - 注册操作:需要事务保证数据一致性
|
||||
* - 状态更新:需要事务保证数据一致性
|
||||
* - 角色更新:需要事务保证数据一致性
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
/**
|
||||
* 密码编码器
|
||||
* 使用 BCrypt 算法对密码进行加密
|
||||
*/
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
* 用于生成和验证 JWT Token
|
||||
*/
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* 用户数据访问接口
|
||||
*/
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* 角色数据访问接口
|
||||
*/
|
||||
private final SysRoleRepository roleRepository;
|
||||
|
||||
/**
|
||||
* Token 服务
|
||||
* 处理 Refresh Token 和黑名单
|
||||
*/
|
||||
private final TokenService tokenService;
|
||||
|
||||
/**
|
||||
* Security 工具类
|
||||
* 获取当前登录用户信息
|
||||
*/
|
||||
private final SecurityUtils securityUtils;
|
||||
|
||||
/**
|
||||
* 登录尝试服务
|
||||
* 处理登录失败计数和账户锁定
|
||||
*/
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* 步骤:
|
||||
* 1. 从 SecurityContext 获取当前用户名
|
||||
* 2. 使用 JOIN FETCH 一次性加载用户及其角色
|
||||
* 3. 转换为视图对象返回
|
||||
*
|
||||
* @return 用户信息视图对象
|
||||
*/
|
||||
@Override
|
||||
public RestBean<UserVo> getUserInfo() {
|
||||
// 1. 获取当前登录用户名
|
||||
String username = SecurityUtils.getUsername();
|
||||
User user = userRepository.findByUsername(username)
|
||||
|
||||
// 2. 查询用户(使用 JOIN FETCH 避免懒加载问题)
|
||||
User user = userRepository.findByUsernameWithRoles(username)
|
||||
.orElse(null);
|
||||
|
||||
// 3. 用户不存在则返回错误
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND);
|
||||
}
|
||||
// 转换为 UserVo 返回
|
||||
UserVo userVo = new UserVo();
|
||||
userVo.setId(user.getId());
|
||||
userVo.setUsername(user.getUsername());
|
||||
userVo.setEmail(user.getEmail());
|
||||
userVo.setStatus(user.getStatus());
|
||||
userVo.setRole(user.getRole().name());
|
||||
userVo.setCreatedAt(user.getCreatedAt());
|
||||
userVo.setUpdatedAt(user.getUpdatedAt());
|
||||
|
||||
// 4. 转换为视图对象
|
||||
UserVo userVo = convertToVo(user);
|
||||
return RestBean.success(userVo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 步骤:
|
||||
* 1. 校验用户名是否已存在
|
||||
* 2. 校验邮箱是否已存在(不区分大小写)
|
||||
* 3. 创建用户实体
|
||||
* 4. 分配默认角色(ROLE_USER)
|
||||
* 5. 保存到数据库
|
||||
* 6. 生成 Access Token 和 Refresh Token
|
||||
*
|
||||
* 注意:
|
||||
* - 密码使用 BCrypt 加密存储
|
||||
* - 新用户默认分配 ROLE_USER 角色
|
||||
* - 返回 Token 可直接用于后续请求
|
||||
*
|
||||
* @param userDto 用户注册信息
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
@Override
|
||||
public RestBean<String> register(UserDto userDto) {
|
||||
String normalizedEmail = userDto.getEmail() == null ? null : userDto.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||
// 检查用户名是否存在
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RestBean<LoginResponseVo> register(UserDto userDto) {
|
||||
// 1. 标准化邮箱(转小写,去除首尾空格)
|
||||
String normalizedEmail = userDto.getEmail() == null ? null
|
||||
: userDto.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
// 2. 检查用户名是否已存在
|
||||
if (userRepository.existsByUsername(userDto.getUsername())) {
|
||||
return RestBean.failure(400, "用户名已被使用", null);
|
||||
}
|
||||
// 检查邮箱是否存在
|
||||
|
||||
// 3. 检查邮箱是否已存在(不区分大小写)
|
||||
if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) {
|
||||
return RestBean.failure(400, "邮箱已被使用", null);
|
||||
}
|
||||
|
||||
// 4. 创建用户实体
|
||||
User user = new User();
|
||||
user.setUsername(userDto.getUsername());
|
||||
// 4.1 密码使用 BCrypt 加密
|
||||
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
|
||||
user.setEmail(normalizedEmail);
|
||||
user.setRole(Role.USER); // 新注册用户默认为普通用户
|
||||
// 默认状态为1(正常),已在实体类中设置
|
||||
|
||||
// 5. 分配默认角色(ROLE_USER)
|
||||
SysRole userRole = roleRepository.findByRoleCode("ROLE_USER")
|
||||
.orElseThrow(() -> new RuntimeException("默认角色 ROLE_USER 未找到"));
|
||||
user.setRoles(Set.of(userRole));
|
||||
|
||||
// 6. 保存用户到数据库
|
||||
userRepository.save(user);
|
||||
|
||||
// 生成token
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
return RestBean.success(token);
|
||||
// 7. 生成 Access Token
|
||||
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
|
||||
// 8. 生成 Refresh Token
|
||||
String refreshToken = tokenService.createRefreshToken(user.getId(), null, null);
|
||||
|
||||
// 9. 构建登录响应
|
||||
LoginResponseVo response = LoginResponseVo.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.expiresIn(jwtUtil.getExpirationSeconds())
|
||||
.userInfo(LoginResponseVo.UserInfoVo.builder()
|
||||
.id(user.getId())
|
||||
.username(user.getUsername())
|
||||
.email(user.getEmail())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// 10. 发送注册消息到 MQ(异步处理)
|
||||
// messageProducer.sendRegisterMessage(user.getId(), user.getUsername(), user.getEmail());
|
||||
|
||||
log.info("用户注册成功 - userId: {}, username: {}", user.getId(), user.getUsername());
|
||||
return RestBean.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 步骤:
|
||||
* 1. 根据用户名查找用户
|
||||
* 2. 检查账户是否被锁定
|
||||
* 3. 检查账户是否被禁用
|
||||
* 4. 验证密码
|
||||
* 5. 记录登录失败(如果密码错误)
|
||||
* 6. 登录成功后重置失败计数
|
||||
* 7. 生成 Access Token 和 Refresh Token
|
||||
* 8. 发送登录消息到 MQ
|
||||
*
|
||||
* 安全机制:
|
||||
* - 连续失败 5 次锁定账户 30 分钟
|
||||
* - 密码错误会记录失败次数
|
||||
* - 登录成功后重置失败计数
|
||||
*
|
||||
* @param userDto 登录信息
|
||||
* @return 登录响应(包含 Token 和用户信息)
|
||||
*/
|
||||
@Override
|
||||
public RestBean<String> login(UserDto userDto) {
|
||||
// 查找用户
|
||||
public RestBean<LoginResponseVo> login(UserDto userDto) {
|
||||
// 1. 查找用户
|
||||
User user = userRepository.findByUsername(userDto.getUsername())
|
||||
.orElse(null);
|
||||
|
||||
// 2. 用户不存在
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, "用户不存在");
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND.getCode(), "用户不存在", null);
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
// 3. 检查账户是否被锁定
|
||||
if (user.isLocked()) {
|
||||
Long remainingMinutes = loginAttemptService.getRemainingLockTimeMinutes(userDto.getUsername());
|
||||
return RestBean.failure(RestCode.USER_LOCKED.getCode(),
|
||||
String.format("账户已被锁定,请在 %d 分钟后重试", remainingMinutes), null);
|
||||
}
|
||||
|
||||
// 4. 检查用户状态(是否被禁用)
|
||||
if (!user.isEnabled()) {
|
||||
return RestBean.failure(403, "用户已被禁用", null);
|
||||
return RestBean.failure(RestCode.USER_DISABLED.getCode(), "用户已被禁用", null);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
// 5. 验证密码
|
||||
if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) {
|
||||
return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
|
||||
// 5.1 记录失败尝试
|
||||
boolean shouldLock = loginAttemptService.recordFailedAttempt(userDto.getUsername());
|
||||
if (shouldLock) {
|
||||
// 5.2 账户被锁定
|
||||
return RestBean.failure(RestCode.USER_LOCKED.getCode(),
|
||||
String.format("登录失败次数过多,账户已被锁定 %d 分钟", 30), null);
|
||||
}
|
||||
// 5.3 密码错误,返回通用错误信息(防止用户名枚举)
|
||||
return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR.getCode(), "用户名或密码错误", null);
|
||||
}
|
||||
|
||||
// 生成token
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
return RestBean.success(token);
|
||||
// 6. 登录成功,重置失败计数
|
||||
loginAttemptService.resetFailedAttempts(userDto.getUsername());
|
||||
|
||||
// 7. 生成 Access Token
|
||||
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
|
||||
// 8. 生成 Refresh Token
|
||||
String refreshToken = tokenService.createRefreshToken(user.getId(), null, null);
|
||||
|
||||
// 9. 构建登录响应
|
||||
LoginResponseVo response = LoginResponseVo.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.expiresIn(jwtUtil.getExpirationSeconds())
|
||||
.userInfo(LoginResponseVo.UserInfoVo.builder()
|
||||
.id(user.getId())
|
||||
.username(user.getUsername())
|
||||
.email(user.getEmail())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// 10. 发送登录消息到 MQ(异步处理,如记录登录日志)
|
||||
// messageProducer.sendLoginMessage(user.getId(), user.getUsername(), ipAddress);
|
||||
|
||||
log.info("用户登录成功 - userId: {}, username: {}", user.getId(), user.getUsername());
|
||||
return RestBean.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询用户列表
|
||||
* 步骤:
|
||||
* 1. 构建分页参数(页码从 0 开始)
|
||||
* 2. 按创建时间倒序排序
|
||||
* 3. 构建动态查询条件
|
||||
* 4. 执行分页查询
|
||||
* 5. 转换结果为视图对象
|
||||
*
|
||||
* 查询条件:
|
||||
* - 关键词:匹配用户名或邮箱
|
||||
* - 状态:精确匹配
|
||||
*
|
||||
* @param queryDto 查询条件
|
||||
* @return 分页用户列表
|
||||
*/
|
||||
@Override
|
||||
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
|
||||
try {
|
||||
// 1. 构建分页参数
|
||||
// - 页码从 0 开始(前端传入的页码需要 -1)
|
||||
// - 每页大小至少为 1
|
||||
Pageable pageable = PageRequest.of(
|
||||
Math.max(queryDto.getPage() - 1, 0),
|
||||
Math.max(queryDto.getSize(), 1),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt")
|
||||
);
|
||||
|
||||
// 2. 执行分页查询
|
||||
Page<User> userPage = userRepository.findAll(buildSpecification(queryDto), pageable);
|
||||
|
||||
// 3. 转换为视图对象列表
|
||||
List<UserVo> records = userPage.getContent().stream()
|
||||
.map(this::convertToVo)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4. 构建分页结果
|
||||
return RestBean.success(PageResult.of(
|
||||
records,
|
||||
userPage.getTotalElements(),
|
||||
@@ -138,58 +338,127 @@ public class UserServiceImpl implements UserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 检查是否修改当前登录用户的状态(禁止)
|
||||
* 3. 更新用户状态
|
||||
* 4. 保存到数据库
|
||||
*
|
||||
* 注意:
|
||||
* - 不能禁用当前登录的用户
|
||||
* - 状态变更需要重新登录才能完全生效
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 状态更新请求
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto) {
|
||||
try {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
|
||||
}
|
||||
|
||||
// 2. 检查是否修改当前登录用户
|
||||
Long currentUserId = SecurityUtils.getUserId();
|
||||
if (currentUserId != null && currentUserId.equals(userId)) {
|
||||
return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null);
|
||||
}
|
||||
|
||||
// 3. 更新用户状态
|
||||
user.setStatus(updateDto.getStatus());
|
||||
return RestBean.success(convertToVo(userRepository.save(user)));
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
log.info("用户状态更新成功 - userId: {}, status: {}", userId, updateDto.getStatus());
|
||||
return RestBean.success(convertToVo(savedUser));
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户状态失败, userId={}", userId, e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户角色
|
||||
* 步骤:
|
||||
* 1. 查询用户是否存在
|
||||
* 2. 检查是否修改当前登录用户的角色(禁止)
|
||||
* 3. 验证所有角色ID是否存在
|
||||
* 4. 清空用户原有角色
|
||||
* 5. 添加新角色
|
||||
* 6. 保存到数据库
|
||||
*
|
||||
* 注意:
|
||||
* - 不能修改当前登录用户的角色
|
||||
* - 角色变更后需要重新登录才能生效
|
||||
* - 清空原有角色再添加新角色(避免重复)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param updateDto 角色更新请求
|
||||
* @return 更新后的用户信息
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto) {
|
||||
try {
|
||||
// 1. 查询用户
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
|
||||
}
|
||||
|
||||
// 2. 检查是否修改当前登录用户
|
||||
Long currentUserId = SecurityUtils.getUserId();
|
||||
if (currentUserId != null && currentUserId.equals(userId)) {
|
||||
return RestBean.failure(403, "不允许修改当前登录用户的角色", null);
|
||||
}
|
||||
|
||||
String roleValue = updateDto.getRole().trim().toUpperCase();
|
||||
if (!"ADMIN".equals(roleValue) && !"USER".equals(roleValue)) {
|
||||
return RestBean.failure(400, "角色不合法", null);
|
||||
// 3. 根据角色ID查询所有角色
|
||||
Set<SysRole> roles = new HashSet<>();
|
||||
for (Long roleId : updateDto.getRoleIds()) {
|
||||
SysRole role = roleRepository.findById(roleId)
|
||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
||||
roles.add(role);
|
||||
}
|
||||
|
||||
Role role = Role.valueOf(roleValue);
|
||||
user.setRole(role);
|
||||
return RestBean.success(convertToVo(userRepository.save(user)));
|
||||
// 4. 更新用户角色(清空后添加)
|
||||
user.getRoles().clear();
|
||||
user.getRoles().addAll(roles);
|
||||
|
||||
// 5. 保存到数据库
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
log.info("用户角色更新成功 - userId: {}, roleIds: {}", userId, updateDto.getRoleIds());
|
||||
return RestBean.success(convertToVo(savedUser));
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户角色失败, userId={}", userId, e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建动态查询条件
|
||||
* 步骤:
|
||||
* 1. 支持按关键词模糊搜索(用户名或邮箱)
|
||||
* 2. 支持按状态精确筛选
|
||||
* 3. 返回 Specification 对象
|
||||
*
|
||||
* 注意:
|
||||
* - 角色查询需要使用 JOIN,此处简化处理
|
||||
* - 可以根据需要扩展更多查询条件
|
||||
*
|
||||
* @param queryDto 查询条件对象
|
||||
* @return 动态查询 Specification
|
||||
*/
|
||||
private Specification<User> buildSpecification(UserQueryDto queryDto) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// 1. 关键词模糊搜索(用户名或邮箱)
|
||||
if (StringUtils.hasText(queryDto.getKeyword())) {
|
||||
String keyword = "%" + queryDto.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(
|
||||
@@ -198,10 +467,7 @@ public class UserServiceImpl implements UserService {
|
||||
));
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(queryDto.getRole())) {
|
||||
predicates.add(cb.equal(root.get("role"), Role.fromString(queryDto.getRole())));
|
||||
}
|
||||
|
||||
// 2. 状态精确筛选
|
||||
if (queryDto.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), queryDto.getStatus()));
|
||||
}
|
||||
@@ -210,13 +476,29 @@ public class UserServiceImpl implements UserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户实体转换为视图对象
|
||||
* 步骤:
|
||||
* 1. 复制基本信息(ID、用户名、邮箱、状态)
|
||||
* 2. 转换角色为角色编码集合
|
||||
* 3. 复制时间信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @return 用户视图对象
|
||||
*/
|
||||
private UserVo convertToVo(User user) {
|
||||
UserVo userVo = new UserVo();
|
||||
userVo.setId(user.getId());
|
||||
userVo.setUsername(user.getUsername());
|
||||
userVo.setEmail(user.getEmail());
|
||||
userVo.setStatus(user.getStatus());
|
||||
userVo.setRole(user.getRole().name());
|
||||
|
||||
// 1. 转换角色为字符串集合(只返回角色编码)
|
||||
Set<String> roleCodes = user.getRoles().stream()
|
||||
.map(SysRole::getRoleCode)
|
||||
.collect(Collectors.toSet());
|
||||
userVo.setRoles(roleCodes);
|
||||
|
||||
userVo.setCreatedAt(user.getCreatedAt());
|
||||
userVo.setUpdatedAt(user.getUpdatedAt());
|
||||
return userVo;
|
||||
|
||||
@@ -8,37 +8,167 @@ import org.springframework.stereotype.Component;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
* 用于生成和验证 JWT Token
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. Token 生成:生成包含用户信息的 JWT Token
|
||||
* 2. Token 解析:解析 Token 并提取信息
|
||||
* 3. Token 验证:验证 Token 签名和有效期
|
||||
*
|
||||
* Token 结构:
|
||||
* - Header:算法类型和 Token 类型
|
||||
* - Payload:用户信息、签发时间、过期时间、jti(JWT ID)
|
||||
* - Signature:使用密钥签名的签名部分
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
/**
|
||||
* JWT 密钥
|
||||
* 从配置文件读取
|
||||
* HS512 算法要求密钥至少 64 字符(512 位)
|
||||
*/
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
private final long expiration = 1000 * 60 * 60 *24; // 24小时
|
||||
/**
|
||||
* Access Token 过期时间(秒)
|
||||
* 从配置文件读取,默认 3600 秒(1 小时)
|
||||
*/
|
||||
@Value("${jwt.access-token-expiration:3600}") // Default 1 hour
|
||||
private long accessTokenExpirationSeconds;
|
||||
|
||||
/**
|
||||
* 获取签名密钥
|
||||
* 说明:
|
||||
* - HS512 算法要求密钥至少 512 位(64 字节)
|
||||
* - 如果密钥不足 64 字符,抛出异常
|
||||
*
|
||||
* @return 签名密钥
|
||||
*/
|
||||
private Key getSigningKey() {
|
||||
// HS512 要求密钥至少 512 位 = 64 字节
|
||||
if (secret.length() < 64) {
|
||||
throw new IllegalArgumentException("JWT secret must be at least 64 characters long for HS512");
|
||||
throw new IllegalArgumentException("JWT 密钥必须至少 64 个字符(HS512 算法要求)");
|
||||
}
|
||||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(Long userId,String username) {
|
||||
/**
|
||||
* 为用户生成 Access Token
|
||||
* 步骤:
|
||||
* 1. 生成唯一的 jti(JWT ID)
|
||||
* 2. 设置用户信息(用户名、用户ID)
|
||||
* 3. 设置签发时间和过期时间
|
||||
* 4. 使用密钥签名
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @return JWT Token
|
||||
*/
|
||||
public String generateToken(Long userId, String username) {
|
||||
return generateToken(userId, username, accessTokenExpirationSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成自定义过期时间的 Token
|
||||
* 步骤:
|
||||
* 1. 生成唯一的 jti(JWT ID),用于黑名单追踪
|
||||
* 2. 计算过期时间(毫秒)
|
||||
* 3. 构建 Token
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param expirationSeconds 过期时间(秒)
|
||||
* @return JWT Token
|
||||
*/
|
||||
public String generateToken(Long userId, String username, long expirationSeconds) {
|
||||
// 1. 生成唯一的 jti(JWT ID)
|
||||
String jti = UUID.randomUUID().toString();
|
||||
long expirationMillis = expirationSeconds * 1000;
|
||||
|
||||
// 2. 构建 Token
|
||||
return Jwts.builder()
|
||||
.setSubject(username)
|
||||
.claim("id", userId)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration))
|
||||
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||
.setSubject(username) // 主题:用户名
|
||||
.claim("id", userId) // 自定义声明:用户ID
|
||||
.claim("jti", jti) // 自定义声明:JWT ID(用于黑名单)
|
||||
.setIssuedAt(new Date()) // 签发时间:当前时间
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expirationMillis)) // 过期时间
|
||||
.signWith(getSigningKey(), SignatureAlgorithm.HS512) // 签名算法和密钥
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取用户名
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return 用户名
|
||||
*/
|
||||
public String extractUsername(String token) {
|
||||
return parseClaims(token).getBody().getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取用户ID
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return 用户ID
|
||||
*/
|
||||
public Long extractUserId(String token) {
|
||||
Claims claims = parseClaims(token).getBody();
|
||||
return claims.get("id", Long.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取 JWT ID(jti)
|
||||
* 说明:
|
||||
* - jti 是 JWT 的唯一标识
|
||||
* - 用于将 Token 加入黑名单(登出功能)
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return JWT ID
|
||||
*/
|
||||
public String extractJti(String token) {
|
||||
Claims claims = parseClaims(token).getBody();
|
||||
return claims.get("jti", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取过期时间
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return 过期时间
|
||||
*/
|
||||
public Date extractExpiration(String token) {
|
||||
return parseClaims(token).getBody().getExpiration();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Access Token 的过期时间(秒)
|
||||
*
|
||||
* @return 过期时间(秒)
|
||||
*/
|
||||
public long getExpirationSeconds() {
|
||||
return accessTokenExpirationSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 签名和结构
|
||||
* 说明:
|
||||
* - 验证签名是否正确
|
||||
* - 验证 Token 结构是否完整
|
||||
* - 不验证过期时间
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return true 表示 Token 有效
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaims(token);
|
||||
@@ -48,6 +178,31 @@ public class JwtUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否过期
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return true 表示 Token 已过期
|
||||
*/
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Date expiration = extractExpiration(token);
|
||||
return expiration.before(new Date());
|
||||
} catch (JwtException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证 JWT Token
|
||||
* 说明:
|
||||
* - 验证签名
|
||||
* - 解析 Claims
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return 解析后的 JWT 对象(包含 Claims)
|
||||
* @throws JwtException 如果 Token 无效
|
||||
*/
|
||||
private Jws<Claims> parseClaims(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
|
||||
762
src/main/java/com/aisi/template/utils/RedisCache.java
Normal file
762
src/main/java/com/aisi/template/utils/RedisCache.java
Normal file
@@ -0,0 +1,762 @@
|
||||
package com.aisi.template.utils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Redis 缓存工具类
|
||||
* 提供各种场景下的缓存操作方法
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 对象缓存:缓存实体对象,减少数据库查询
|
||||
* 2. 列表缓存:缓存列表数据
|
||||
* 3. 集合缓存:缓存去重数据
|
||||
* 4. 哈希缓存:缓存对象字段
|
||||
* 5. 计数器:文章阅读数、点赞数等
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RedisCache {
|
||||
|
||||
/**
|
||||
* Redis 模板
|
||||
*/
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
// ==================== 1. String 类型操作 ====================
|
||||
|
||||
/**
|
||||
* 设置缓存(永不过期)
|
||||
* 注意:生产环境建议设置过期时间,防止内存溢出
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
*/
|
||||
public void set(String key, Object value) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key, value);
|
||||
log.debug("设置缓存成功 - key: {}", key);
|
||||
} catch (Exception e) {
|
||||
log.error("设置缓存失败 - key: {}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存(指定过期时间)
|
||||
* 步骤:
|
||||
* 1. 将值序列化后存入 Redis
|
||||
* 2. 设置过期时间
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param timeout 过期时间
|
||||
* @param timeUnit 时间单位
|
||||
*/
|
||||
public void set(String key, Object value, long timeout, TimeUnit timeUnit) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
|
||||
log.debug("设置缓存成功 - key: {}, timeout: {} {}", key, timeout, timeUnit);
|
||||
} catch (Exception e) {
|
||||
log.error("设置缓存失败 - key: {}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存(使用 Duration)
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param duration 过期时间
|
||||
*/
|
||||
public void set(String key, Object value, Duration duration) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key, value, duration);
|
||||
log.debug("设置缓存成功 - key: {}, duration: {}", key, duration);
|
||||
} catch (Exception e) {
|
||||
log.error("设置缓存失败 - key: {}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* 步骤:
|
||||
* 1. 从 Redis 获取值
|
||||
* 2. 反序列化为对象
|
||||
*
|
||||
* @param key 键
|
||||
* @return 值,不存在返回 null
|
||||
*/
|
||||
public Object get(String key) {
|
||||
try {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
log.debug("获取缓存 - key: {}, found: {}", key, value != null);
|
||||
return value;
|
||||
} catch (Exception e) {
|
||||
log.error("获取缓存失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存(指定类型)
|
||||
*
|
||||
* @param key 键
|
||||
* @param type 返回值类型
|
||||
* @return 值
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T get(String key, Class<T> type) {
|
||||
try {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
if (value != null && type.isInstance(value)) {
|
||||
return (T) value;
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("获取缓存失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
*
|
||||
* @param key 键
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
public boolean delete(String key) {
|
||||
try {
|
||||
Boolean result = redisTemplate.delete(key);
|
||||
log.debug("删除缓存 - key: {}, result: {}", key, result);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("删除缓存失败 - key: {}", key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除缓存
|
||||
* 步骤:
|
||||
* 1. 遍历所有键
|
||||
* 2. 一次性删除
|
||||
*
|
||||
* @param keys 键集合
|
||||
* @return 删除的数量
|
||||
*/
|
||||
public long delete(Collection<String> keys) {
|
||||
try {
|
||||
Long count = redisTemplate.delete(keys);
|
||||
log.debug("批量删除缓存 - count: {}", count);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("批量删除缓存失败", e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断键是否存在
|
||||
*
|
||||
* @param key 键
|
||||
* @return 是否存在
|
||||
*/
|
||||
public boolean hasKey(String key) {
|
||||
try {
|
||||
Boolean result = redisTemplate.hasKey(key);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("判断键是否存在失败 - key: {}", key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
*
|
||||
* @param key 键
|
||||
* @param timeout 过期时间
|
||||
* @param unit 时间单位
|
||||
* @return 是否设置成功
|
||||
*/
|
||||
public boolean expire(String key, long timeout, TimeUnit unit) {
|
||||
try {
|
||||
Boolean result = redisTemplate.expire(key, timeout, unit);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("设置过期时间失败 - key: {}", key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过期时间
|
||||
*
|
||||
* @param key 键
|
||||
* @param unit 时间单位
|
||||
* @return 剩余过期时间,-1 表示永不过期,-2 表示键不存在
|
||||
*/
|
||||
public long getExpire(String key, TimeUnit unit) {
|
||||
try {
|
||||
Long expire = redisTemplate.getExpire(key, unit);
|
||||
return expire != null ? expire : -2;
|
||||
} catch (Exception e) {
|
||||
log.error("获取过期时间失败 - key: {}", key, e);
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增操作(原子性)
|
||||
* 使用场景:计数器、点赞数、阅读数等
|
||||
* 步骤:
|
||||
* 1. 原子性自增
|
||||
* 2. 返回自增后的值
|
||||
*
|
||||
* @param key 键
|
||||
* @param delta 增量
|
||||
* @return 自增后的值
|
||||
*/
|
||||
public long increment(String key, long delta) {
|
||||
try {
|
||||
Long result = redisTemplate.opsForValue().increment(key, delta);
|
||||
log.debug("自增操作 - key: {}, delta: {}, result: {}", key, delta, result);
|
||||
return result != null ? result : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("自增操作失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自减操作(原子性)
|
||||
*
|
||||
* @param key 键
|
||||
* @param delta 减量
|
||||
* @return 自减后的值
|
||||
*/
|
||||
public long decrement(String key, long delta) {
|
||||
try {
|
||||
Long result = redisTemplate.opsForValue().decrement(key, delta);
|
||||
log.debug("自减操作 - key: {}, delta: {}, result: {}", key, delta, result);
|
||||
return result != null ? result : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("自减操作失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2. Hash 类型操作 ====================
|
||||
|
||||
/**
|
||||
* 设置哈希字段
|
||||
* 使用场景:缓存对象的单个字段
|
||||
* 步骤:
|
||||
* 1. 将字段和值存入哈希表
|
||||
* 2. 适合部分更新对象字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param field 字段
|
||||
* @param value 值
|
||||
*/
|
||||
public void hSet(String key, String field, Object value) {
|
||||
try {
|
||||
redisTemplate.opsForHash().put(key, field, value);
|
||||
log.debug("设置哈希字段 - key: {}, field: {}", key, field);
|
||||
} catch (Exception e) {
|
||||
log.error("设置哈希字段失败 - key: {}, field: {}", key, field, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param field 字段
|
||||
* @return 值
|
||||
*/
|
||||
public Object hGet(String key, String field) {
|
||||
try {
|
||||
return redisTemplate.opsForHash().get(key, field);
|
||||
} catch (Exception e) {
|
||||
log.error("获取哈希字段失败 - key: {}, field: {}", key, field, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param map 字段-值映射
|
||||
*/
|
||||
public void hSetAll(String key, Map<String, Object> map) {
|
||||
try {
|
||||
redisTemplate.opsForHash().putAll(key, map);
|
||||
log.debug("批量设置哈希字段 - key: {}", key);
|
||||
} catch (Exception e) {
|
||||
log.error("批量设置哈希字段失败 - key: {}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @return 字段-值映射
|
||||
*/
|
||||
public Map<Object, Object> hGetAll(String key) {
|
||||
try {
|
||||
return redisTemplate.opsForHash().entries(key);
|
||||
} catch (Exception e) {
|
||||
log.error("获取所有哈希字段失败 - key: {}", key, e);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param fields 字段集合
|
||||
* @return 删除的数量
|
||||
*/
|
||||
public long hDelete(String key, Object... fields) {
|
||||
try {
|
||||
return redisTemplate.opsForHash().delete(key, fields);
|
||||
} catch (Exception e) {
|
||||
log.error("删除哈希字段失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断哈希字段是否存在
|
||||
*
|
||||
* @param key 键
|
||||
* @param field 字段
|
||||
* @return 是否存在
|
||||
*/
|
||||
public boolean hExists(String key, String field) {
|
||||
try {
|
||||
return redisTemplate.opsForHash().hasKey(key, field);
|
||||
} catch (Exception e) {
|
||||
log.error("判断哈希字段是否存在失败 - key: {}, field: {}", key, field, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希字段自增
|
||||
*
|
||||
* @param key 键
|
||||
* @param field 字段
|
||||
* @param delta 增量
|
||||
* @return 自增后的值
|
||||
*/
|
||||
public long hIncrement(String key, String field, long delta) {
|
||||
try {
|
||||
return redisTemplate.opsForHash().increment(key, field, delta);
|
||||
} catch (Exception e) {
|
||||
log.error("哈希字段自增失败 - key: {}, field: {}", key, field, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 3. List 类型操作 ====================
|
||||
|
||||
/**
|
||||
* 从左侧推入列表
|
||||
* 使用场景:消息队列、最新消息列表
|
||||
* 步骤:
|
||||
* 1. 将元素推入列表左侧
|
||||
* 2. 返回当前列表长度
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 推入后的列表长度
|
||||
*/
|
||||
public long lLeftPush(String key, Object value) {
|
||||
try {
|
||||
Long size = redisTemplate.opsForList().leftPush(key, value);
|
||||
log.debug("从左侧推入列表 - key: {}, size: {}", key, size);
|
||||
return size != null ? size : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("从左侧推入列表失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧推入列表
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 推入后的列表长度
|
||||
*/
|
||||
public long lRightPush(String key, Object value) {
|
||||
try {
|
||||
Long size = redisTemplate.opsForList().rightPush(key, value);
|
||||
log.debug("从右侧推入列表 - key: {}, size: {}", key, size);
|
||||
return size != null ? size : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("从右侧推入列表失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从左侧弹出列表元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 弹出的元素
|
||||
*/
|
||||
public Object lLeftPop(String key) {
|
||||
try {
|
||||
return redisTemplate.opsForList().leftPop(key);
|
||||
} catch (Exception e) {
|
||||
log.error("从左侧弹出列表元素失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧弹出列表元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 弹出的元素
|
||||
*/
|
||||
public Object lRightPop(String key) {
|
||||
try {
|
||||
return redisTemplate.opsForList().rightPop(key);
|
||||
} catch (Exception e) {
|
||||
log.error("从右侧弹出列表元素失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表范围
|
||||
* 使用场景:分页查询列表数据
|
||||
* 步骤:
|
||||
* 1. 获取指定范围的元素
|
||||
* 2. 支持负索引(-1 表示最后一个元素)
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
* @return 元素列表
|
||||
*/
|
||||
public List<Object> lRange(String key, long start, long end) {
|
||||
try {
|
||||
return redisTemplate.opsForList().range(key, start, end);
|
||||
} catch (Exception e) {
|
||||
log.error("获取列表范围失败 - key: {}", key, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表长度
|
||||
*
|
||||
* @param key 键
|
||||
* @return 列表长度
|
||||
*/
|
||||
public long lSize(String key) {
|
||||
try {
|
||||
Long size = redisTemplate.opsForList().size(key);
|
||||
return size != null ? size : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("获取列表长度失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除列表元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param count 移除数量(>0 从左往右,<0 从右往左,=0 全部)
|
||||
* @param value 要移除的值
|
||||
* @return 实际移除的数量
|
||||
*/
|
||||
public long lRemove(String key, long count, Object value) {
|
||||
try {
|
||||
Long removed = redisTemplate.opsForList().remove(key, count, value);
|
||||
return removed != null ? removed : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("移除列表元素失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 4. Set 类型操作 ====================
|
||||
|
||||
/**
|
||||
* 添加到集合
|
||||
* 使用场景:标签系统、共同好友、去重
|
||||
* 步骤:
|
||||
* 1. 添加元素到集合
|
||||
* 2. 自动去重
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值集合
|
||||
* @return 添加的元素数量(不包含已存在的)
|
||||
*/
|
||||
public long sAdd(String key, Object... values) {
|
||||
try {
|
||||
Long count = redisTemplate.opsForSet().add(key, values);
|
||||
log.debug("添加到集合 - key: {}, count: {}", key, count);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("添加到集合失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合所有元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> sMembers(String key) {
|
||||
try {
|
||||
return redisTemplate.opsForSet().members(key);
|
||||
} catch (Exception e) {
|
||||
log.error("获取集合元素失败 - key: {}", key, e);
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断元素是否在集合中
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 是否存在
|
||||
*/
|
||||
public boolean sIsMember(String key, Object value) {
|
||||
try {
|
||||
Boolean result = redisTemplate.opsForSet().isMember(key, value);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("判断元素是否在集合中失败 - key: {}", key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合大小
|
||||
*
|
||||
* @param key 键
|
||||
* @return 集合大小
|
||||
*/
|
||||
public long sSize(String key) {
|
||||
try {
|
||||
Long size = redisTemplate.opsForSet().size(key);
|
||||
return size != null ? size : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("获取集合大小失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除集合元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 要移除的值
|
||||
* @return 实际移除的数量
|
||||
*/
|
||||
public long sRemove(String key, Object... values) {
|
||||
try {
|
||||
Long count = redisTemplate.opsForSet().remove(key, values);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("移除集合元素失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 集合交集
|
||||
* 使用场景:共同好友、共同标签
|
||||
* 步骤:
|
||||
* 1. 获取多个集合的交集
|
||||
* 2. 返回共有的元素
|
||||
*
|
||||
* @param key1 第一个集合键
|
||||
* @param key2 第二个集合键
|
||||
* @return 交集元素
|
||||
*/
|
||||
public Set<Object> sIntersect(String key1, String key2) {
|
||||
try {
|
||||
return redisTemplate.opsForSet().intersect(key1, key2);
|
||||
} catch (Exception e) {
|
||||
log.error("获取集合交集失败 - key1: {}, key2: {}", key1, key2, e);
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 集合并集
|
||||
*
|
||||
* @param key1 第一个集合键
|
||||
* @param key2 第二个集合键
|
||||
* @return 并集元素
|
||||
*/
|
||||
public Set<Object> sUnion(String key1, String key2) {
|
||||
try {
|
||||
return redisTemplate.opsForSet().union(key1, key2);
|
||||
} catch (Exception e) {
|
||||
log.error("获取集合并集失败 - key1: {}, key2: {}", key1, key2, e);
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 5. ZSet 类型操作(有序集合)====================
|
||||
|
||||
/**
|
||||
* 添加到有序集合
|
||||
* 使用场景:排行榜、权重队列
|
||||
* 步骤:
|
||||
* 1. 添加元素及分数
|
||||
* 2. 按分数自动排序
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param score 分数
|
||||
* @return 添加是否成功(新增返回 true,更新返回 false)
|
||||
*/
|
||||
public boolean zAdd(String key, Object value, double score) {
|
||||
try {
|
||||
Boolean result = redisTemplate.opsForZSet().add(key, value, score);
|
||||
log.debug("添加到有序集合 - key: {}, value: {}, score: {}", key, value, score);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("添加到有序集合失败 - key: {}", key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合范围(按分数)
|
||||
* 使用场景:排行榜分页查询
|
||||
* 步骤:
|
||||
* 1. 获取指定分数范围的元素
|
||||
* 2. 按分数升序排列
|
||||
*
|
||||
* @param key 键
|
||||
* @param min 最小分数
|
||||
* @param max 最大分数
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> zRangeByScore(String key, double min, double max) {
|
||||
try {
|
||||
return redisTemplate.opsForZSet().rangeByScore(key, min, max);
|
||||
} catch (Exception e) {
|
||||
log.error("获取有序集合范围失败 - key: {}", key, e);
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合排名(倒序)
|
||||
* 使用场景:排行榜查询(分数高的排名靠前)
|
||||
* 步骤:
|
||||
* 1. 获取元素的排名(从0开始)
|
||||
* 2. 按分数倒序排列
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 排名,不存在返回 null
|
||||
*/
|
||||
public Long zReverseRank(String key, Object value) {
|
||||
try {
|
||||
return redisTemplate.opsForZSet().reverseRank(key, value);
|
||||
} catch (Exception e) {
|
||||
log.error("获取有序集合排名失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素分数
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 分数
|
||||
*/
|
||||
public Double zScore(String key, Object value) {
|
||||
try {
|
||||
return redisTemplate.opsForZSet().score(key, value);
|
||||
} catch (Exception e) {
|
||||
log.error("获取元素分数失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加元素分数
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param delta 增量
|
||||
* @return 增加后的分数
|
||||
*/
|
||||
public Double zIncrementScore(String key, Object value, double delta) {
|
||||
try {
|
||||
return redisTemplate.opsForZSet().incrementScore(key, value, delta);
|
||||
} catch (Exception e) {
|
||||
log.error("增加元素分数失败 - key: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除有序集合元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 要移除的值
|
||||
* @return 实际移除的数量
|
||||
*/
|
||||
public long zRemove(String key, Object... values) {
|
||||
try {
|
||||
Long count = redisTemplate.opsForZSet().remove(key, values);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("移除有序集合元素失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合大小
|
||||
*
|
||||
* @param key 键
|
||||
* @return 集合大小
|
||||
*/
|
||||
public long zSize(String key) {
|
||||
try {
|
||||
Long size = redisTemplate.opsForZSet().size(key);
|
||||
return size != null ? size : 0;
|
||||
} catch (Exception e) {
|
||||
log.error("获取有序集合大小失败 - key: {}", key, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
301
src/main/java/com/aisi/template/utils/RedisLock.java
Normal file
301
src/main/java/com/aisi/template/utils/RedisLock.java
Normal file
@@ -0,0 +1,301 @@
|
||||
package com.aisi.template.utils;
|
||||
|
||||
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.util.Collections;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Redis 分布式锁工具类
|
||||
* 基于 Redis SETNX + EXPIRE 实现,支持可重入锁
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 防止重复提交(如表单重复提交)
|
||||
* 2. 库存扣减(防止超卖)
|
||||
* 3. 定时任务分布式执行(防止重复执行)
|
||||
* 4. 限流场景
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RedisLock {
|
||||
|
||||
/**
|
||||
* Redis 模板
|
||||
*/
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* 锁前缀
|
||||
*/
|
||||
private static final String LOCK_PREFIX = "lock:";
|
||||
|
||||
/**
|
||||
* 锁的值前缀
|
||||
*/
|
||||
private static final String LOCK_VALUE_PREFIX = "uuid:";
|
||||
|
||||
/**
|
||||
* 默认锁过期时间(秒)
|
||||
*/
|
||||
private static final long DEFAULT_EXPIRE_TIME = 30;
|
||||
|
||||
/**
|
||||
* 获取锁的 Lua 脚本
|
||||
* SETNX + EXPIRE 原子操作
|
||||
*/
|
||||
private static final String LOCK_SCRIPT =
|
||||
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
|
||||
"redis.call('expire', KEYS[1], ARGV[2]) " +
|
||||
"return 1 " +
|
||||
"else " +
|
||||
"return 0 " +
|
||||
"end";
|
||||
|
||||
/**
|
||||
* 释放锁的 Lua 脚本
|
||||
* 确保只有锁的持有者才能释放锁
|
||||
*/
|
||||
private static final String UNLOCK_SCRIPT =
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
|
||||
"return redis.call('del', KEYS[1]) " +
|
||||
"else " +
|
||||
"return 0 " +
|
||||
"end";
|
||||
|
||||
/**
|
||||
* 尝试获取锁
|
||||
* 步骤:
|
||||
* 1. 生成唯一的锁标识(UUID)
|
||||
* 2. 执行 Lua 脚本尝试获取锁
|
||||
* 3. 如果获取成功,返回锁标识;否则返回 null
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @return 锁标识,如果获取失败返回 null
|
||||
*/
|
||||
public String tryLock(String lockKey) {
|
||||
return tryLock(lockKey, DEFAULT_EXPIRE_TIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取锁(指定过期时间)
|
||||
* 步骤:
|
||||
* 1. 生成唯一的锁标识(UUID)
|
||||
* 2. 执行 Lua 脚本尝试获取锁,并设置过期时间
|
||||
* 3. 如果获取成功,返回锁标识;否则返回 null
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @param expireTime 锁的过期时间(秒)
|
||||
* @return 锁标识,如果获取失败返回 null
|
||||
*/
|
||||
public String tryLock(String lockKey, long expireTime) {
|
||||
// 1. 生成唯一的锁标识
|
||||
String lockValue = LOCK_VALUE_PREFIX + UUID.randomUUID().toString();
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
|
||||
try {
|
||||
// 2. 执行 Lua 脚本获取锁
|
||||
// KEYS[1]: 锁的完整键名
|
||||
// ARGV[1]: 锁的值(UUID)
|
||||
// ARGV[2]: 过期时间(秒)
|
||||
Long result = redisTemplate.execute(
|
||||
RedisScript.of(LOCK_SCRIPT, Long.class),
|
||||
Collections.singletonList(fullLockKey),
|
||||
lockValue,
|
||||
String.valueOf(expireTime)
|
||||
);
|
||||
|
||||
// 3. 判断是否获取成功
|
||||
if (result != null && result == 1) {
|
||||
log.info("获取锁成功 - lockKey: {}, lockValue: {}", lockKey, lockValue);
|
||||
return lockValue;
|
||||
} else {
|
||||
log.warn("获取锁失败 - lockKey: {}", lockKey);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("获取锁异常 - lockKey: {}", lockKey, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取锁(带等待时间)
|
||||
* 步骤:
|
||||
* 1. 在等待时间内循环尝试获取锁
|
||||
* 2. 每次间隔 100 毫秒重试
|
||||
* 3. 超过等待时间则放弃
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @param expireTime 锁的过期时间(秒)
|
||||
* @param waitTime 等待时间(毫秒)
|
||||
* @return 锁标识,如果获取失败返回 null
|
||||
*/
|
||||
public String tryLock(String lockKey, long expireTime, long waitTime) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
long timeout = startTime + waitTime;
|
||||
String lockValue = null;
|
||||
|
||||
// 1. 在等待时间内循环尝试
|
||||
while (System.currentTimeMillis() < timeout) {
|
||||
// 2. 尝试获取锁
|
||||
lockValue = tryLock(lockKey, expireTime);
|
||||
if (lockValue != null) {
|
||||
return lockValue;
|
||||
}
|
||||
|
||||
// 3. 短暂休眠后重试
|
||||
try {
|
||||
TimeUnit.MILLISECONDS.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("获取锁超时 - lockKey: {}, waitTime: {}ms", lockKey, waitTime);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
* 步骤:
|
||||
* 1. 验证锁的值是否匹配(确保只有持有者才能释放)
|
||||
* 2. 如果匹配则删除锁,否则不做操作
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @param lockValue 锁的标识(tryLock 返回的值)
|
||||
* @return 是否释放成功
|
||||
*/
|
||||
public boolean unlock(String lockKey, String lockValue) {
|
||||
if (lockValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
|
||||
try {
|
||||
// 1. 执行 Lua 脚本释放锁
|
||||
// KEYS[1]: 锁的完整键名
|
||||
// ARGV[1]: 锁的值(用于验证)
|
||||
Long result = redisTemplate.execute(
|
||||
RedisScript.of(UNLOCK_SCRIPT, Long.class),
|
||||
Collections.singletonList(fullLockKey),
|
||||
lockValue
|
||||
);
|
||||
|
||||
// 2. 判断是否释放成功
|
||||
if (result != null && result == 1) {
|
||||
log.info("释放锁成功 - lockKey: {}, lockValue: {}", lockKey, lockValue);
|
||||
return true;
|
||||
} else {
|
||||
log.warn("释放锁失败,锁不存在或已过期 - lockKey: {}, lockValue: {}", lockKey, lockValue);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("释放锁异常 - lockKey: {}, lockValue: {}", lockKey, lockValue, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制释放锁(不验证持有者)
|
||||
* 注意:这个方法会强制删除锁,可能导致其他持有者的锁被误删
|
||||
* 仅在特殊场景使用,如清理死锁
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
public boolean forceUnlock(String lockKey) {
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
try {
|
||||
Boolean result = redisTemplate.delete(fullLockKey);
|
||||
log.warn("强制释放锁 - lockKey: {}, result: {}", lockKey, result);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("强制释放锁异常 - lockKey: {}", lockKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查锁是否存在
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @return 是否存在
|
||||
*/
|
||||
public boolean isLocked(String lockKey) {
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
try {
|
||||
Boolean result = redisTemplate.hasKey(fullLockKey);
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (Exception e) {
|
||||
log.error("检查锁状态异常 - lockKey: {}", lockKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁的剩余过期时间
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @return 剩余过期时间(秒),-1 表示不存在或已过期
|
||||
*/
|
||||
public long getLockExpireTime(String lockKey) {
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
try {
|
||||
Long expireTime = redisTemplate.getExpire(fullLockKey, TimeUnit.SECONDS);
|
||||
return expireTime != null ? expireTime : -1;
|
||||
} catch (Exception e) {
|
||||
log.error("获取锁过期时间异常 - lockKey: {}", lockKey, e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期锁(延长锁的过期时间)
|
||||
* 注意:需要验证持有者
|
||||
*
|
||||
* @param lockKey 锁的键名
|
||||
* @param lockValue 锁的标识
|
||||
* @param addTime 增加的时间(秒)
|
||||
* @return 是否续期成功
|
||||
*/
|
||||
public boolean renewLock(String lockKey, String lockValue, long addTime) {
|
||||
if (lockValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String fullLockKey = LOCK_PREFIX + lockKey;
|
||||
|
||||
try {
|
||||
// 1. 先验证锁的值
|
||||
String currentValue = (String) redisTemplate.opsForValue().get(fullLockKey);
|
||||
if (!lockValue.equals(currentValue)) {
|
||||
log.warn("续期失败,锁的值不匹配 - lockKey: {}", lockKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 续期锁
|
||||
Boolean result = redisTemplate.expire(fullLockKey, addTime, TimeUnit.SECONDS);
|
||||
if (Boolean.TRUE.equals(result)) {
|
||||
log.info("续期锁成功 - lockKey: {}, addTime: {}s", lockKey, addTime);
|
||||
return true;
|
||||
} else {
|
||||
log.warn("续期锁失败 - lockKey: {}", lockKey);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("续期锁异常 - lockKey: {}", lockKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.aisi.template.utils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ZSetOperations;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -11,69 +12,187 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Redis 工具类
|
||||
* 提供各种 Redis 数据类型的便捷操作方法
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. String 类型:字符串、对象缓存
|
||||
* 2. Hash 类型:对象字段缓存
|
||||
* 3. List 类型:队列、列表
|
||||
* 4. Set 类型:去重、交集、并集
|
||||
* 5. ZSet 类型:排行榜
|
||||
* 6. Bitmap:位图操作
|
||||
* 7. HyperLogLog:基数统计
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RedisUtils {
|
||||
|
||||
/**
|
||||
* Redis 模板
|
||||
*/
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
// ======================== 通用 ========================
|
||||
/**
|
||||
* 字符串 Redis 模板,用于数值自增等原生命令
|
||||
*/
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
// ======================== 1. 通用操作 ========================
|
||||
|
||||
/**
|
||||
* 删除键
|
||||
*
|
||||
* @param key 键
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
public Boolean delete(String key) {
|
||||
return redisTemplate.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除键
|
||||
*
|
||||
* @param keys 键集合
|
||||
* @return 删除的数量
|
||||
*/
|
||||
public Long delete(Collection<String> keys) {
|
||||
return redisTemplate.delete(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断键是否存在
|
||||
*
|
||||
* @param key 键
|
||||
* @return 是否存在
|
||||
*/
|
||||
public Boolean hasKey(String key) {
|
||||
return redisTemplate.hasKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
*
|
||||
* @param key 键
|
||||
* @param timeout 过期时间
|
||||
* @param unit 时间单位
|
||||
* @return 是否设置成功
|
||||
*/
|
||||
public Boolean expire(String key, long timeout, TimeUnit unit) {
|
||||
return redisTemplate.expire(key, timeout, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过期时间
|
||||
*
|
||||
* @param key 键
|
||||
* @return 剩余过期时间(秒),-1 表示永不过期,-2 表示键不存在
|
||||
*/
|
||||
public Long getExpire(String key) {
|
||||
return redisTemplate.getExpire(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定时间戳过期
|
||||
*
|
||||
* @param key 键
|
||||
* @param timestamp 时间戳(毫秒)
|
||||
* @return 是否设置成功
|
||||
*/
|
||||
public Boolean expireAt(String key, long timestamp) {
|
||||
return redisTemplate.expireAt(key, new java.util.Date(timestamp));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找匹配模式的键
|
||||
*
|
||||
* @param pattern 模式(如:user:*)
|
||||
* @return 匹配的键集合
|
||||
*/
|
||||
public Set<String> keys(String pattern) {
|
||||
return redisTemplate.keys(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名键
|
||||
*
|
||||
* @param oldKey 旧键名
|
||||
* @param newKey 新键名
|
||||
* @return 是否重命名成功
|
||||
*/
|
||||
public Boolean rename(String oldKey, String newKey) {
|
||||
redisTemplate.rename(oldKey, newKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键的剩余生存时间(秒)
|
||||
*
|
||||
* @param key 键
|
||||
* @return 剩余秒数
|
||||
*/
|
||||
public Long ttl(String key) {
|
||||
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ======================== String ========================
|
||||
// ======================== 2. String 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 设置键值
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
*/
|
||||
public void set(String key, Object value) {
|
||||
redisTemplate.opsForValue().set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置键值(带过期时间)
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param timeout 过期时间
|
||||
* @param unit 时间单位
|
||||
*/
|
||||
public void set(String key, Object value, long timeout, TimeUnit unit) {
|
||||
redisTemplate.opsForValue().set(key, value, timeout, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果键不存在则设置(原子操作)
|
||||
* 使用场景:分布式锁
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param timeout 过期时间
|
||||
* @param unit 时间单位
|
||||
*/
|
||||
public void setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
|
||||
redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键值
|
||||
*
|
||||
* @param key 键
|
||||
* @return 值
|
||||
*/
|
||||
public Object get(String key) {
|
||||
return redisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键值(指定类型)
|
||||
*
|
||||
* @param key 键
|
||||
* @param clazz 类型
|
||||
* @return 值
|
||||
*/
|
||||
public <T> T get(String key, Class<T> clazz) {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
if (value == null) {
|
||||
@@ -82,255 +201,680 @@ public class RedisUtils {
|
||||
return clazz.cast(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并设置新值
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 新值
|
||||
* @return 旧值
|
||||
*/
|
||||
public String getAndSet(String key, Object value) {
|
||||
Object old = redisTemplate.opsForValue().getAndSet(key, value);
|
||||
return old != null ? old.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增(原子操作)
|
||||
*
|
||||
* @param key 键
|
||||
* @return 自增后的值
|
||||
*/
|
||||
public Long increment(String key) {
|
||||
return redisTemplate.opsForValue().increment(key);
|
||||
return stringRedisTemplate.opsForValue().increment(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增指定增量(原子操作)
|
||||
*
|
||||
* @param key 键
|
||||
* @param delta 增量
|
||||
* @return 自增后的值
|
||||
*/
|
||||
public Long increment(String key, long delta) {
|
||||
return redisTemplate.opsForValue().increment(key, delta);
|
||||
return stringRedisTemplate.opsForValue().increment(key, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自减(原子操作)
|
||||
*
|
||||
* @param key 键
|
||||
* @return 自减后的值
|
||||
*/
|
||||
public Long decrement(String key) {
|
||||
return redisTemplate.opsForValue().decrement(key);
|
||||
return stringRedisTemplate.opsForValue().decrement(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自减指定减量(原子操作)
|
||||
*
|
||||
* @param key 键
|
||||
* @param delta 减量
|
||||
* @return 自减后的值
|
||||
*/
|
||||
public Long decrement(String key, long delta) {
|
||||
return redisTemplate.opsForValue().decrement(key, delta);
|
||||
return stringRedisTemplate.opsForValue().decrement(key, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符串长度
|
||||
*
|
||||
* @param key 键
|
||||
* @return 字符串长度
|
||||
*/
|
||||
public Long strLength(String key) {
|
||||
return redisTemplate.opsForValue().size(key);
|
||||
}
|
||||
|
||||
// ======================== Hash ========================
|
||||
// ======================== 3. Hash 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 设置哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param hashKey 哈希键
|
||||
* @param value 值
|
||||
*/
|
||||
public void hSet(String key, String hashKey, Object value) {
|
||||
redisTemplate.opsForHash().put(key, hashKey, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param map 哈希键值映射
|
||||
*/
|
||||
public void hSetAll(String key, Map<String, Object> map) {
|
||||
redisTemplate.opsForHash().putAll(key, map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param hashKey 哈希键
|
||||
* @return 值
|
||||
*/
|
||||
public Object hGet(String key, String hashKey) {
|
||||
return redisTemplate.opsForHash().get(key, hashKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @return 哈希键值映射
|
||||
*/
|
||||
public Map<Object, Object> hGetAll(String key) {
|
||||
return redisTemplate.opsForHash().entries(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除哈希字段
|
||||
*
|
||||
* @param key 键
|
||||
* @param hashKeys 哈希键集合
|
||||
*/
|
||||
public void hDelete(String key, Object... hashKeys) {
|
||||
redisTemplate.opsForHash().delete(key, hashKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断哈希字段是否存在
|
||||
*
|
||||
* @param key 键
|
||||
* @param hashKey 哈希键
|
||||
* @return 是否存在
|
||||
*/
|
||||
public Boolean hHasKey(String key, String hashKey) {
|
||||
return redisTemplate.opsForHash().hasKey(key, hashKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取哈希大小
|
||||
*
|
||||
* @param key 键
|
||||
* @return 哈希大小
|
||||
*/
|
||||
public Long hSize(String key) {
|
||||
return redisTemplate.opsForHash().size(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希字段自增
|
||||
*
|
||||
* @param key 键
|
||||
* @param hashKey 哈希键
|
||||
* @param delta 增量
|
||||
* @return 自增后的值
|
||||
*/
|
||||
public Long hIncrement(String key, String hashKey, long delta) {
|
||||
return redisTemplate.opsForHash().increment(key, hashKey, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有哈希键
|
||||
*
|
||||
* @param key 键
|
||||
* @return 哈希键集合
|
||||
*/
|
||||
public Set<Object> hKeys(String key) {
|
||||
return redisTemplate.opsForHash().keys(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有哈希值
|
||||
*
|
||||
* @param key 键
|
||||
* @return 哈希值列表
|
||||
*/
|
||||
public List<Object> hValues(String key) {
|
||||
return redisTemplate.opsForHash().values(key);
|
||||
}
|
||||
|
||||
// ======================== List ========================
|
||||
// ======================== 4. List 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 从左侧推入列表
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 列表长度
|
||||
*/
|
||||
public Long lPush(String key, Object value) {
|
||||
return redisTemplate.opsForList().leftPush(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从左侧批量推入列表
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值数组
|
||||
* @return 列表长度
|
||||
*/
|
||||
public Long lPushAll(String key, Object... values) {
|
||||
return redisTemplate.opsForList().leftPushAll(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧推入列表
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 列表长度
|
||||
*/
|
||||
public Long rPush(String key, Object value) {
|
||||
return redisTemplate.opsForList().rightPush(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧批量推入列表
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值数组
|
||||
* @return 列表长度
|
||||
*/
|
||||
public Long rPushAll(String key, Object... values) {
|
||||
return redisTemplate.opsForList().rightPushAll(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从左侧弹出元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 弹出的元素
|
||||
*/
|
||||
public Object lPop(String key) {
|
||||
return redisTemplate.opsForList().leftPop(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从左侧批量弹出元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param count 数量
|
||||
* @return 弹出的元素列表
|
||||
*/
|
||||
public List<Object> lPop(String key, long count) {
|
||||
return redisTemplate.opsForList().leftPop(key, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧弹出元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 弹出的元素
|
||||
*/
|
||||
public Object rPop(String key) {
|
||||
return redisTemplate.opsForList().rightPop(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧批量弹出元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param count 数量
|
||||
* @return 弹出的元素列表
|
||||
*/
|
||||
public List<Object> rPop(String key, long count) {
|
||||
return redisTemplate.opsForList().rightPop(key, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表指定索引的元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param index 索引(0 表示第一个)
|
||||
* @return 元素
|
||||
*/
|
||||
public Object lIndex(String key, long index) {
|
||||
return redisTemplate.opsForList().index(key, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表长度
|
||||
*
|
||||
* @param key 键
|
||||
* @return 列表长度
|
||||
*/
|
||||
public Long lSize(String key) {
|
||||
return redisTemplate.opsForList().size(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表范围
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
* @return 元素列表
|
||||
*/
|
||||
public List<Object> lRange(String key, long start, long end) {
|
||||
return redisTemplate.opsForList().range(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪列表(只保留指定范围的元素)
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
*/
|
||||
public void lTrim(String key, long start, long end) {
|
||||
redisTemplate.opsForList().trim(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置列表指定索引的元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param index 索引
|
||||
* @param value 值
|
||||
*/
|
||||
public void lSet(String key, long index, Object value) {
|
||||
redisTemplate.opsForList().set(key, index, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除列表元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param count 移除数量(>0 从左往右,<0 从右往左,=0 全部)
|
||||
* @param value 要移除的值
|
||||
* @return 实际移除的数量
|
||||
*/
|
||||
public Long lRemove(String key, long count, Object value) {
|
||||
return redisTemplate.opsForList().remove(key, count, value);
|
||||
}
|
||||
|
||||
// ======================== Set ========================
|
||||
// ======================== 5. Set 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 添加到集合
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值数组
|
||||
* @return 添加的元素数量(不包含已存在的)
|
||||
*/
|
||||
public Long sAdd(String key, Object... values) {
|
||||
return redisTemplate.opsForSet().add(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从集合移除元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值数组
|
||||
* @return 实际移除的数量
|
||||
*/
|
||||
public Long sRemove(String key, Object... values) {
|
||||
return redisTemplate.opsForSet().remove(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合所有元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> sMembers(String key) {
|
||||
return redisTemplate.opsForSet().members(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断元素是否在集合中
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 是否存在
|
||||
*/
|
||||
public Boolean sIsMember(String key, Object value) {
|
||||
return redisTemplate.opsForSet().isMember(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合大小
|
||||
*
|
||||
* @param key 键
|
||||
* @return 集合大小
|
||||
*/
|
||||
public Long sSize(String key) {
|
||||
return redisTemplate.opsForSet().size(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机获取集合中的一个元素
|
||||
*
|
||||
* @param key 键
|
||||
* @return 随机元素
|
||||
*/
|
||||
public Object sRandomMember(String key) {
|
||||
return redisTemplate.opsForSet().randomMember(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机获取集合中的多个元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param count 数量
|
||||
* @return 随机元素集合
|
||||
*/
|
||||
public Set<Object> sRandomMembers(String key, long count) {
|
||||
return redisTemplate.opsForSet().distinctRandomMembers(key, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 集合交集
|
||||
*
|
||||
* @param key1 第一个集合键
|
||||
* @param key2 第二个集合键
|
||||
* @return 交集元素
|
||||
*/
|
||||
public Set<Object> sIntersect(String key1, String key2) {
|
||||
return redisTemplate.opsForSet().intersect(key1, key2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 集合并集
|
||||
*
|
||||
* @param key1 第一个集合键
|
||||
* @param key2 第二个集合键
|
||||
* @return 并集元素
|
||||
*/
|
||||
public Set<Object> sUnion(String key1, String key2) {
|
||||
return redisTemplate.opsForSet().union(key1, key2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 集合差集(key1 - key2)
|
||||
*
|
||||
* @param key1 第一个集合键
|
||||
* @param key2 第二个集合键
|
||||
* @return 差集元素
|
||||
*/
|
||||
public Set<Object> sDifference(String key1, String key2) {
|
||||
return redisTemplate.opsForSet().difference(key1, key2);
|
||||
}
|
||||
|
||||
// ======================== ZSet(有序集合)========================
|
||||
// ======================== 6. ZSet 类型操作(有序集合)====================
|
||||
|
||||
/**
|
||||
* 添加到有序集合
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param score 分数
|
||||
* @return 是否添加成功
|
||||
*/
|
||||
public Boolean zAdd(String key, Object value, double score) {
|
||||
return redisTemplate.opsForZSet().add(key, value, score);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加到有序集合
|
||||
*
|
||||
* @param key 键
|
||||
* @param tuples 分数-值对集合
|
||||
* @return 添加的数量
|
||||
*/
|
||||
public Long zAdd(String key, Set<ZSetOperations.TypedTuple<Object>> tuples) {
|
||||
return redisTemplate.opsForZSet().add(key, tuples);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从有序集合移除元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 要移除的值
|
||||
* @return 移除的数量
|
||||
*/
|
||||
public Long zRemove(String key, Object... values) {
|
||||
return redisTemplate.opsForZSet().remove(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素排名(升序)
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 排名(从 0 开始),不存在返回 null
|
||||
*/
|
||||
public Long zRank(String key, Object value) {
|
||||
return redisTemplate.opsForZSet().rank(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素排名(降序)
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 排名(从 0 开始),不存在返回 null
|
||||
*/
|
||||
public Long zReverseRank(String key, Object value) {
|
||||
return redisTemplate.opsForZSet().reverseRank(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合范围(升序)
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> zRange(String key, long start, long end) {
|
||||
return redisTemplate.opsForZSet().range(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合范围(降序)
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> zReverseRange(String key, long start, long end) {
|
||||
return redisTemplate.opsForZSet().reverseRange(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合范围(带分数,升序)
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始索引
|
||||
* @param end 结束索引
|
||||
* @return 分数-值对集合
|
||||
*/
|
||||
public Set<ZSetOperations.TypedTuple<Object>> zRangeWithScores(String key, long start, long end) {
|
||||
return redisTemplate.opsForZSet().rangeWithScores(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定分数范围的元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param min 最小分数
|
||||
* @param max 最大分数
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> zRangeByScore(String key, double min, double max) {
|
||||
return redisTemplate.opsForZSet().rangeByScore(key, min, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定分数范围的元素(分页)
|
||||
*
|
||||
* @param key 键
|
||||
* @param min 最小分数
|
||||
* @param max 最大分数
|
||||
* @param offset 偏移量
|
||||
* @param count 数量
|
||||
* @return 元素集合
|
||||
*/
|
||||
public Set<Object> zRangeByScore(String key, double min, double max, long offset, long count) {
|
||||
return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定分数范围内的元素数量
|
||||
*
|
||||
* @param key 键
|
||||
* @param min 最小分数
|
||||
* @param max 最大分数
|
||||
* @return 元素数量
|
||||
*/
|
||||
public Long zCount(String key, double min, double max) {
|
||||
return redisTemplate.opsForZSet().count(key, min, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有序集合大小
|
||||
*
|
||||
* @param key 键
|
||||
* @return 集合大小
|
||||
*/
|
||||
public Long zSize(String key) {
|
||||
return redisTemplate.opsForZSet().size(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素分数
|
||||
*
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @return 分数
|
||||
*/
|
||||
public Double zScore(String key, Object value) {
|
||||
return redisTemplate.opsForZSet().score(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定排名范围的元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param start 开始排名
|
||||
* @param end 结束排名
|
||||
* @return 移除的数量
|
||||
*/
|
||||
public Long zRemoveRange(String key, long start, long end) {
|
||||
return redisTemplate.opsForZSet().removeRange(key, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定分数范围的元素
|
||||
*
|
||||
* @param key 键
|
||||
* @param min 最小分数
|
||||
* @param max 最大分数
|
||||
* @return 移除的数量
|
||||
*/
|
||||
public Long zRemoveRangeByScore(String key, double min, double max) {
|
||||
return redisTemplate.opsForZSet().removeRangeByScore(key, min, max);
|
||||
}
|
||||
|
||||
// ======================== Bitmap ========================
|
||||
// ======================== 7. Bitmap 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 设置位图的指定位
|
||||
* 使用场景:
|
||||
* - 用户签到统计
|
||||
* - 在线状态统计
|
||||
* - 布隆过滤器
|
||||
*
|
||||
* @param key 键
|
||||
* @param offset 偏移量(位位置)
|
||||
* @param value 值(true=1,false=0)
|
||||
* @return 设置前的值
|
||||
*/
|
||||
public Boolean setBit(String key, long offset, boolean value) {
|
||||
return redisTemplate.opsForValue().setBit(key, offset, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取位图的指定位
|
||||
*
|
||||
* @param key 键
|
||||
* @param offset 偏移量(位位置)
|
||||
* @return 位值(true=1,false=0)
|
||||
*/
|
||||
public Boolean getBit(String key, long offset) {
|
||||
return redisTemplate.opsForValue().getBit(key, offset);
|
||||
}
|
||||
|
||||
// ======================== HyperLogLog ========================
|
||||
// ======================== 8. HyperLogLog 类型操作 ========================
|
||||
|
||||
/**
|
||||
* 添加元素到 HyperLogLog
|
||||
* 使用场景:
|
||||
* - 统计独立访客数(UV)
|
||||
* - 统计独立元素数(基数统计)
|
||||
* - 优点:内存占用小,适合大数据量
|
||||
*
|
||||
* @param key 键
|
||||
* @param values 值数组
|
||||
* @return 添加后的基数
|
||||
*/
|
||||
public Long pfAdd(String key, Object... values) {
|
||||
return redisTemplate.opsForHyperLogLog().add(key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计基数
|
||||
*
|
||||
* @param keys 键数组(可统计多个键的并集基数)
|
||||
* @return 基数
|
||||
*/
|
||||
public Long pfCount(String... keys) {
|
||||
return redisTemplate.opsForHyperLogLog().size(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 HyperLogLog
|
||||
*
|
||||
* @param destination 目标键
|
||||
* @param sourceKeys 源键数组
|
||||
*/
|
||||
public void pfMerge(String destination, String... sourceKeys) {
|
||||
redisTemplate.opsForHyperLogLog().union(destination, sourceKeys);
|
||||
}
|
||||
|
||||
@@ -6,26 +6,56 @@ import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Security 工具类
|
||||
* 提供获取当前登录用户信息的便捷方法
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 获取当前认证对象
|
||||
* 2. 获取当前用户名
|
||||
* 3. 获取当前用户ID
|
||||
*
|
||||
* 使用场景:
|
||||
* - 在 Service 层获取当前用户信息
|
||||
* - 在拦截器中获取当前用户信息
|
||||
* - 在日志中记录操作用户
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2024-04-09
|
||||
*/
|
||||
@Component
|
||||
public class SecurityUtils {
|
||||
|
||||
/**
|
||||
* 获取当前 Authentication
|
||||
* 获取当前 Authentication 对象
|
||||
* 说明:
|
||||
* - Authentication 包含认证信息和用户详情
|
||||
* - 如果用户未认证,返回 null
|
||||
*
|
||||
* @return 当前认证对象
|
||||
*/
|
||||
public static Authentication getAuthentication(){
|
||||
public static Authentication getAuthentication() {
|
||||
return SecurityContextHolder.getContext().getAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录名
|
||||
* 获取当前登录用户的用户名
|
||||
* 步骤:
|
||||
* 1. 获取当前认证对象
|
||||
* 2. 检查用户是否已认证
|
||||
* 3. 从 Principal 中提取用户名
|
||||
*
|
||||
* @return 用户名,未登录返回 null
|
||||
*/
|
||||
public static String getUsername(){
|
||||
public static String getUsername() {
|
||||
// 1. 获取当前认证对象
|
||||
Authentication auth = getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated()) {
|
||||
Object principal = auth.getPrincipal();
|
||||
// 2. 判断 Principal 类型并提取用户名
|
||||
if (principal instanceof CustomUserDetails user) {
|
||||
return user.getUsername();
|
||||
}else if (principal instanceof String username) {
|
||||
return user.getUsername();
|
||||
} else if (principal instanceof String username) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
@@ -33,14 +63,21 @@ public class SecurityUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户Id
|
||||
* 获取当前登录用户的用户ID
|
||||
* 步骤:
|
||||
* 1. 获取当前认证对象
|
||||
* 2. 检查用户是否已认证
|
||||
* 3. 从 CustomUserDetails 中提取用户ID
|
||||
*
|
||||
* @return 用户ID,未登录返回 null
|
||||
*/
|
||||
public static Long getUserId() {
|
||||
// 1. 获取当前认证对象
|
||||
Authentication auth = getAuthentication();
|
||||
// 2. 检查是否已认证且 Principal 类型为 CustomUserDetails
|
||||
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomUserDetails user) {
|
||||
return user.getId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@ spring:
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
ddl-auto: none
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
database-platform: org.hibernate.dialect.MySQLDialect
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
locations: classpath:db/migration
|
||||
encoding: UTF-8
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
@@ -23,6 +28,8 @@ spring:
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DB:0}
|
||||
timeout: 5000ms
|
||||
repositories:
|
||||
enabled: false
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
@@ -44,12 +51,19 @@ spring:
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!}
|
||||
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600} # 1 hour in seconds
|
||||
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7 days in seconds
|
||||
|
||||
app:
|
||||
password-reset:
|
||||
code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10}
|
||||
request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60}
|
||||
max-attempts: ${PASSWORD_RESET_MAX_ATTEMPTS:5}
|
||||
login:
|
||||
max-attempts: ${LOGIN_MAX_ATTEMPTS:5}
|
||||
lock-duration-minutes: ${LOGIN_LOCK_DURATION_MINUTES:30}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
32
src/main/resources/application-rocketmq.yaml
Normal file
32
src/main/resources/application-rocketmq.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# RocketMQ 配置
|
||||
rocketmq:
|
||||
# NameServer 地址(单机)
|
||||
name-server: localhost:9876
|
||||
# 生产者配置
|
||||
producer:
|
||||
# 生产者组名
|
||||
group: user-producer-group
|
||||
# 发送消息超时时间(毫秒)
|
||||
send-message-timeout: 3000
|
||||
# 消息最大大小(字节)
|
||||
max-message-size: 4194304
|
||||
# 失败重试次数
|
||||
retry-times-when-send-failed: 2
|
||||
# 异步发送失败重试次数
|
||||
retry-times-when-send-async-failed: 2
|
||||
# 压缩阈值(字节)
|
||||
compress-message-body-threshold: 4096
|
||||
# 用户主题名称
|
||||
user-topic: user-topic
|
||||
# 消费者配置
|
||||
consumer:
|
||||
# 消费者组名
|
||||
group: user-consumer-group
|
||||
# 消费线程数(最小)
|
||||
consume-thread-min: 5
|
||||
# 消费线程数(最大)
|
||||
consume-thread-max: 10
|
||||
# 消息最大重试次数
|
||||
max-reconsume-times: 3
|
||||
# 消息超时时间(分钟)
|
||||
consume-timeout: 15
|
||||
@@ -5,6 +5,11 @@ spring:
|
||||
active: dev
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
# 默认排除 RocketMQ 自动配置,启用时通过 profile 激活
|
||||
# 启用方式:spring.profiles.active=dev,rocketmq
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
148
src/main/resources/db/migration/V1__baseline_schema.sql
Normal file
148
src/main/resources/db/migration/V1__baseline_schema.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- V1: Baseline schema for new template projects.
|
||||
-- Flyway runs this file automatically on the first startup.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `email` varchar(255) DEFAULT NULL, `password` varchar(255) NOT NULL,
|
||||
`username` varchar(50) NOT NULL, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1=enabled 0=disabled',
|
||||
`failed_login_count` int NOT NULL DEFAULT 0, `locked_until` datetime DEFAULT NULL,
|
||||
`password_changed_at` datetime DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY uk_users_username (`username`), UNIQUE KEY uk_users_email (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `code_hash` varchar(64) NOT NULL,
|
||||
`expires_at` datetime NOT NULL, `used` bit(1) NOT NULL DEFAULT b'0', `attempt_count` int NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), KEY `idx_password_reset_email` (`email`), KEY `idx_password_reset_expires_at` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `token_hash` varchar(128) NOT NULL,
|
||||
`device_info` varchar(255) DEFAULT NULL, `ip_address` varchar(45) DEFAULT NULL, `expires_at` datetime NOT NULL,
|
||||
`revoked` tinyint(1) NOT NULL DEFAULT b'0', `revoked_at` datetime DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_refresh_tokens_hash` (`token_hash`), KEY `idx_refresh_tokens_user_id` (`user_id`),
|
||||
KEY `idx_refresh_tokens_expires_at` (`expires_at`), KEY `idx_refresh_tokens_revoked` (`revoked`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Refresh token storage';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_role (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `role_code` varchar(50) NOT NULL, `role_name` varchar(100) NOT NULL,
|
||||
`description` varchar(500) DEFAULT NULL, `sort_order` int NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_code` (`role_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System role table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_permission (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `permission_code` varchar(100) NOT NULL, `permission_name` varchar(100) NOT NULL,
|
||||
`resource` varchar(50) NOT NULL, `action` varchar(50) NOT NULL, `description` varchar(500) DEFAULT NULL,
|
||||
`status` tinyint NOT NULL DEFAULT 1, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_permission_code` (`permission_code`),
|
||||
KEY `idx_sys_permission_resource` (`resource`), KEY `idx_sys_permission_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System permission table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_menu (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `parent_id` bigint NOT NULL DEFAULT 0, `menu_name` varchar(50) NOT NULL,
|
||||
`menu_type` tinyint NOT NULL, `menu_path` varchar(200) DEFAULT NULL, `component` varchar(200) DEFAULT NULL,
|
||||
`icon` varchar(100) DEFAULT NULL, `sort_order` int NOT NULL DEFAULT 0, `visible` tinyint NOT NULL DEFAULT 1,
|
||||
`status` tinyint NOT NULL DEFAULT 1, `permission_code` varchar(100) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), KEY `idx_sys_menu_parent_id` (`parent_id`), KEY `idx_sys_menu_type` (`menu_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System menu table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_user_role (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `role_id` bigint NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_user_role` (`user_id`, `role_id`), KEY `idx_sys_user_role_user` (`user_id`), KEY `idx_sys_user_role_role` (`role_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='User-Role mapping table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_role_permission (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `permission_id` bigint NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_permission` (`role_id`, `permission_id`), KEY `idx_sys_role_permission_role` (`role_id`), KEY `idx_sys_role_permission_perm` (`permission_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Role-Permission mapping table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_role_menu (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `menu_id` bigint NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_menu` (`role_id`, `menu_id`), KEY `idx_sys_role_menu_role` (`role_id`), KEY `idx_sys_role_menu_menu` (`menu_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Role-Menu mapping table';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint DEFAULT NULL, `username` varchar(50) DEFAULT NULL,
|
||||
`action` varchar(50) NOT NULL, `resource` varchar(100) NOT NULL, `resource_id` varchar(50) DEFAULT NULL,
|
||||
`description` varchar(500) DEFAULT NULL, `request_method` varchar(10) DEFAULT NULL, `request_uri` varchar(500) DEFAULT NULL,
|
||||
`ip_address` varchar(45) DEFAULT NULL, `user_agent` varchar(500) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT 1,
|
||||
`error_message` varchar(1000) DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), KEY `idx_audit_log_user_id` (`user_id`), KEY `idx_audit_log_action` (`action`),
|
||||
KEY `idx_audit_log_resource` (`resource`), KEY `idx_audit_log_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System audit log table';
|
||||
|
||||
INSERT INTO sys_role (`role_code`, `role_name`, `description`, `sort_order`) VALUES
|
||||
('ROLE_SUPER_ADMIN', 'Super Administrator', 'Super administrator with all permissions', 1),
|
||||
('ROLE_ADMIN', 'Administrator', 'Administrator with management permissions', 2),
|
||||
('ROLE_USER', 'User', 'Regular user with basic permissions', 3);
|
||||
|
||||
INSERT INTO sys_permission (`permission_code`, `permission_name`, `resource`, `action`, `description`) VALUES
|
||||
('user:create', 'Create User', 'user', 'create', 'Create new user'),
|
||||
('user:read', 'View User', 'user', 'read', 'View user details'),
|
||||
('user:update', 'Update User', 'user', 'update', 'Update user information'),
|
||||
('user:delete', 'Delete User', 'user', 'delete', 'Delete user'),
|
||||
('user:list', 'List Users', 'user', 'list', 'List all users'),
|
||||
('user:reset-password', 'Reset Password', 'user', 'reset-password', 'Reset user password'),
|
||||
('role:create', 'Create Role', 'role', 'create', 'Create new role'),
|
||||
('role:read', 'View Role', 'role', 'read', 'View role details'),
|
||||
('role:update', 'Update Role', 'role', 'update', 'Update role information'),
|
||||
('role:delete', 'Delete Role', 'role', 'delete', 'Delete role'),
|
||||
('role:list', 'List Roles', 'role', 'list', 'List all roles'),
|
||||
('role:assign-permission', 'Assign Permissions', 'role', 'assign-permission', 'Assign permissions to role'),
|
||||
('permission:list', 'List Permissions', 'permission', 'list', 'List all permissions'),
|
||||
('menu:create', 'Create Menu', 'menu', 'create', 'Create new menu'),
|
||||
('menu:read', 'View Menu', 'menu', 'read', 'View menu details'),
|
||||
('menu:update', 'Update Menu', 'menu', 'update', 'Update menu information'),
|
||||
('menu:delete', 'Delete Menu', 'menu', 'delete', 'Delete menu'),
|
||||
('menu:list', 'List Menus', 'menu', 'list', 'List all menus');
|
||||
|
||||
INSERT INTO sys_menu (`parent_id`, `menu_name`, `menu_type`, `menu_path`, `component`, `icon`, `sort_order`, `permission_code`) VALUES
|
||||
(0, 'System Management', 1, '/system', NULL, 'setting', 1, NULL),
|
||||
(1, 'User Management', 2, '/system/users', 'system/user/index', 'user', 1, 'user:list'),
|
||||
(2, 'Add User', 3, NULL, NULL, NULL, 1, 'user:create'),
|
||||
(2, 'Edit User', 3, NULL, NULL, NULL, 2, 'user:update'),
|
||||
(2, 'Delete User', 3, NULL, NULL, NULL, 3, 'user:delete'),
|
||||
(2, 'Reset Password', 3, NULL, NULL, NULL, 4, 'user:reset-password'),
|
||||
(1, 'Role Management', 2, '/system/roles', 'system/role/index', 'team', 2, 'role:list'),
|
||||
(7, 'Add Role', 3, NULL, NULL, NULL, 1, 'role:create'),
|
||||
(7, 'Edit Role', 3, NULL, NULL, NULL, 2, 'role:update'),
|
||||
(7, 'Delete Role', 3, NULL, NULL, NULL, 3, 'role:delete'),
|
||||
(7, 'Assign Permissions', 3, NULL, NULL, NULL, 4, 'role:assign-permission'),
|
||||
(1, 'Menu Management', 2, '/system/menus', 'system/menu/index', 'menu', 3, 'menu:list'),
|
||||
(12, 'Add Menu', 3, NULL, NULL, NULL, 1, 'menu:create'),
|
||||
(12, 'Edit Menu', 3, NULL, NULL, NULL, 2, 'menu:update'),
|
||||
(12, 'Delete Menu', 3, NULL, NULL, NULL, 3, 'menu:delete');
|
||||
|
||||
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
|
||||
WHERE r.role_code = 'ROLE_SUPER_ADMIN';
|
||||
|
||||
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
|
||||
WHERE r.role_code = 'ROLE_ADMIN'
|
||||
AND p.permission_code IN ('user:list', 'user:read', 'user:update', 'user:reset-password', 'role:list', 'role:read', 'permission:list');
|
||||
|
||||
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
|
||||
WHERE r.role_code = 'ROLE_USER' AND p.permission_code IN ('user:read');
|
||||
|
||||
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
|
||||
WHERE r.role_code = 'ROLE_SUPER_ADMIN';
|
||||
|
||||
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
|
||||
WHERE r.role_code = 'ROLE_ADMIN'
|
||||
AND m.menu_name IN ('System Management', 'User Management', 'Role Management', 'Menu Management');
|
||||
|
||||
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
|
||||
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
|
||||
WHERE r.role_code = 'ROLE_USER' AND m.menu_name = 'User Management';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- V2: Example Flyway migration.
|
||||
-- Keep this small file to show template users how automatic migrations work.
|
||||
-- Add future schema changes as V3__xxx.sql, V4__xxx.sql, ...
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_schema_version_note (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` varchar(50) NOT NULL,
|
||||
`description` varchar(255) NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_app_schema_version_note_version` (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Flyway migration example note';
|
||||
|
||||
INSERT INTO app_schema_version_note (`version`, `description`)
|
||||
VALUES ('V2', 'Example migration applied by Flyway automatically');
|
||||
@@ -1,35 +0,0 @@
|
||||
-- ============================================
|
||||
-- Template database initialization script
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`username` varchar(50) NOT NULL,
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=enabled 0=disabled',
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'Role: USER or ADMIN',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY uk_users_username (`username`),
|
||||
UNIQUE KEY uk_users_email (`email`),
|
||||
KEY idx_users_role (`role`)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`code_hash` varchar(64) NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`used` bit(1) NOT NULL DEFAULT b'0',
|
||||
`attempt_count` int NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_password_reset_email` (`email`),
|
||||
KEY `idx_password_reset_expires_at` (`expires_at`)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_0900_ai_ci;
|
||||
Reference in New Issue
Block a user