Commit 5b81bad6 by Lizh

引入redis配置,实现防重复提交验证,优化自定义线程配置

parent 502edea0
......@@ -12,4 +12,5 @@
/logs/
.trae/
.vscode/
CLAUDE.md
......@@ -4,12 +4,11 @@ import com.jomalls.custom.app.annotation.RequiresPermissions;
import com.jomalls.custom.app.enums.CodeEnum;
import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.app.service.PermissionService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
......@@ -18,12 +17,11 @@ import java.lang.reflect.Method;
/**
* 权限校验切面
*/
@Slf4j
@Aspect
@Component
public class PermissionAspect {
private static final Logger log = LoggerFactory.getLogger(PermissionAspect.class);
@Autowired
private PermissionService permissionService;
......
package com.jomalls.custom.app.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jomalls.custom.app.annotation.RepeatSubmit;
import com.jomalls.custom.app.enums.CodeEnum;
import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.app.utils.RequestHolder;
import com.jomalls.custom.security.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* 防重复提交切面
* 基于Redis SET NX EX实现,在指定时间间隔(毫秒)内同一用户对同一接口的相同参数请求只允许执行一次
* 使用方法:
* 在需要防止重复提交的方法上添加@RepeatSubmit注解,指定间隔时间(毫秒)和提示消息
* 示例:
* @RepeatSubmit(interval = 5000, message = "请勿重复提交")
* @PostMapping("/save")
* public R<Void> save(@RequestBody XxxVO vo) { ... }
*
*/
@Slf4j
@Aspect
@Component
public class RepeatSubmitAspect {
private static final String REDIS_KEY_PREFIX = "custom:repeat:submit:";
private static final String REPEAT_SUBMIT_VALUE = "1";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Around("@annotation(repeatSubmit)")
public Object around(ProceedingJoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
// 构建防重复提交的Redis key
String redisKey = buildRedisKey(point);
int interval = repeatSubmit.interval();
String message = repeatSubmit.message();
// SET NX EX:成功返回true表示首次提交,false表示重复提交
Boolean isFirst = stringRedisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", interval, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(isFirst)) {
log.debug("防重复提交检查通过,key: {}", redisKey);
return point.proceed();
} else {
log.warn("重复提交拦截,key: {},间隔: {}ms", redisKey, interval);
throw new ServiceException(message, CodeEnum.FORBIDDEN.getCode());
}
}
/**
* 构建Redis Key
* 格式:custom:repeat:submit:{用户标识}:{类名.方法名}:{参数MD5}
*/
private String buildRedisKey(ProceedingJoinPoint point) throws Exception {
// 用户标识:优先使用userId,未登录时使用客户端IP
String userKey = getUserKey();
// 方法标识:全限定类名.方法名
String methodKey = point.getSignature().getDeclaringTypeName()
+ "." + point.getSignature().getName();
// 参数标识:参数JSON的MD5值
String paramKey = DigestUtils.md5DigestAsHex(
OBJECT_MAPPER.writeValueAsString(point.getArgs()).getBytes(StandardCharsets.UTF_8));
return REDIS_KEY_PREFIX + userKey + ":" + methodKey + ":" + paramKey;
}
/**
* 获取用户标识
* 已登录用户使用userId,未登录用户使用客户端IP
*/
private String getUserKey() {
Long userId = SecurityUtils.getUserId();
if (userId != null) {
return userId.toString();
}
// 未登录用户使用客户端IP
return getClientIp();
}
/**
* 获取客户端IP地址
*/
private String getClientIp() {
try {
HttpServletRequest request = RequestHolder.getRequestHolder();
if (request != null) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip != null ? ip : "unknown";
}
} catch (Exception e) {
log.debug("获取客户端IP异常: {}", e.getMessage());
}
return "unknown";
}
}
package com.jomalls.custom.config;
import com.jomalls.custom.security.LoginUser;
import com.jomalls.custom.security.SecurityUtils;
import org.jspecify.annotations.NonNull;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @Author: Lizh
* @Date: 2026/6/2 16:27
* @Description: 上下文传递线程池,将主线程的LoginUser传递到工作线程,确保异步任务中可以获取当前用户信息
* @Version: 1.0
*/
public class CustomServerThreadPoolExecutor extends ThreadPoolExecutor {
public CustomServerThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
}
@Override
public void execute(@NonNull Runnable command) {
// 在提交线程中捕获用户信息
LoginUser loginUser = SecurityUtils.getLoginUser();
super.execute(() -> {
// 在工作线程中恢复上下文
if (loginUser != null) {
SecurityUtils.setLoginUser(loginUser);
}
try {
command.run();
} finally {
// 清理ThreadLocal,防止内存泄漏
SecurityUtils.clearLoginUser();
}
});
}
}
package com.jomalls.custom.config;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.resource.ResourceResolver;
import org.springframework.web.servlet.resource.ResourceResolverChain;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.List;
public class PathPatternResourceResolver implements ResourceResolver {
@Override
public Resource resolveResource(HttpServletRequest request, String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
for (Resource location : locations) {
try {
Resource resource = location.createRelative(requestPath);
if (resource.exists() && !requestPath.contains("..")) {
return resource;
}
} catch (IOException e) {
}
}
return chain.resolveResource(request, requestPath, locations);
}
@Override
public String resolveUrlPath(String resourcePath, List<? extends Resource> locations, ResourceResolverChain chain) {
return chain.resolveUrlPath(resourcePath, locations);
}
}
\ No newline at end of file
package com.jomalls.custom.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @Author: Lizh
* @Date: 2026/6/2 15:27
* @Description: redis配置类
* @Version: 1.0
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key使用String序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// Value使用Jackson JSON序列化
template.setValueSerializer(RedisSerializer.json());
template.setHashValueSerializer(RedisSerializer.json());
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
......@@ -2,9 +2,11 @@
package com.jomalls.custom.config;
import com.jomalls.custom.app.utils.RequestHolder;
import com.jomalls.custom.security.LoginUser;
import com.jomalls.custom.security.SecurityUtils;
import com.jomalls.custom.security.TokenHandle;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
......@@ -20,11 +22,10 @@ import jakarta.servlet.http.HttpServletResponse;
* 安全拦截器
* 负责用户登录权限检查,保存登录信息到线程本地变量
*/
@Slf4j
@Component
public class SecurityInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(SecurityInterceptor.class);
@Autowired
private TokenHandle tokenHandle;
......@@ -41,6 +42,8 @@ public class SecurityInterceptor implements HandlerInterceptor {
if (!needAuthentication) {
return true;
}
// 设置 RequestHolder
RequestHolder.setRequestHolder(request);
// 获取登录用户信息
LoginUser loginUser = tokenHandle.getLoginUser(request);
......@@ -67,6 +70,8 @@ public class SecurityInterceptor implements HandlerInterceptor {
*/
@Override
public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) throws Exception {
// 清除 RequestHolder
RequestHolder.removeRequestHolder();
// 清除线程本地变量
SecurityUtils.clearLoginUser();
}
......
......@@ -6,35 +6,33 @@ import org.jspecify.annotations.NonNull;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: Lizh
* @Date: 2026/6/2 15:27
* @Description: 自定义线程工厂,统一线程命名、守护状态、优先级和异常处理
* @Version: 1.0
*/
@Slf4j
public class ThreadFactoryHandle implements ThreadFactory {
private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
private final ThreadGroup group;
private final String namePrefix;
private static final AtomicInteger THREAD_NUMBER = new AtomicInteger(1);
private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
private final AtomicInteger threadNumber = new AtomicInteger(1);
public ThreadFactoryHandle() {
this.group = Thread.currentThread().getThreadGroup();
this.namePrefix = "custom-server-" + POOL_NUMBER.getAndIncrement() + "-thread-";
}
@Override
public Thread newThread(@NonNull Runnable r) {
Thread t = new Thread(group, r,namePrefix + THREAD_NUMBER.getAndIncrement(),0);
log.debug("线程池创建的线程 --- :{}", t.threadId());
if (t.isDaemon()){
t.setDaemon(false);
}
if (t.getPriority() != Thread.NORM_PRIORITY){
t.setPriority(Thread.NORM_PRIORITY);
}
t.setUncaughtExceptionHandler((thread,exception)->{
try {
throw exception;
} catch (Throwable throwable) {
log.error("线程池运行异常:{}",throwable.getMessage(), throwable);
}
});
Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
t.setDaemon(false);
t.setPriority(Thread.NORM_PRIORITY);
t.setUncaughtExceptionHandler((thread, ex) ->
log.error("线程[{}]运行异常", thread.getName(), ex));
log.debug("创建线程: {}", t.getName());
return t;
}
ThreadFactoryHandle() {
group = Thread.currentThread().getThreadGroup();
namePrefix = "custom-server-" + POOL_NUMBER.getAndIncrement() + "-thread-";
}
}
\ No newline at end of file
}
package com.jomalls.custom.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @Author: Lizh
* @Date: 2026/6/2 16:27
* @Description: 线程池自动配置
* @Version: 1.0
*/
@Slf4j
@Configuration
public class ThreadPoolAutoConfiguration {
/** 核心线程数 */
@Value("${custom-server.thread-pool.core-size:#{T(java.lang.Runtime).getRuntime().availableProcessors() + 1}}")
private int corePoolSize;
/** 最大线程数 */
@Value("${custom-server.thread-pool.max-size:#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}}")
private int maxPoolSize;
/** 队列容量 */
@Value("${custom-server.thread-pool.queue-capacity:3000}")
private int queueCapacity;
/** 线程空闲存活时间(秒) */
@Value("${custom-server.thread-pool.keep-alive:60}")
private long keepAliveTime;
/** 拒绝策略:默认使用CallerRunsPolicy,队列满时由调用线程执行 */
@Value("${custom-server.thread-pool.rejection-policy:CALLER_RUNS}")
private String rejectionPolicy;
@Bean("customServerThreadPool")
public ThreadPoolExecutor labelCenterThreadPoolExecutor() {
return new ThreadPoolExecutorConfig(Runtime.getRuntime().availableProcessors() + 1,
Runtime.getRuntime().availableProcessors()*2,
60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(3000),
new ThreadFactoryHandle());
public ThreadPoolExecutor customServerThreadPool() {
RejectedExecutionHandler handler = resolveRejectionHandler();
log.info("初始化自定义线程池,核心线程数: {},最大线程数: {},队列容量: {},拒绝策略: {}",
corePoolSize, maxPoolSize, queueCapacity, rejectionPolicy);
return new CustomServerThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new ThreadFactoryHandle(),
handler);
}
/**
* 根据配置名称解析拒绝策略
*/
private RejectedExecutionHandler resolveRejectionHandler() {
return switch (rejectionPolicy.toUpperCase()) {
case "ABORT" -> new ThreadPoolExecutor.AbortPolicy();
case "DISCARD" -> new ThreadPoolExecutor.DiscardPolicy();
case "DISCARD_OLDEST" -> new ThreadPoolExecutor.DiscardOldestPolicy();
default -> new ThreadPoolExecutor.CallerRunsPolicy();
};
}
}
\ No newline at end of file
}
package com.jomalls.custom.config;
import com.jomalls.custom.app.utils.RequestHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.jspecify.annotations.NonNull;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorConfig extends ThreadPoolExecutor {
public ThreadPoolExecutorConfig(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
}
@Override
public void execute(@NonNull Runnable command) {
//设置子线程共享
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
RequestContextHolder.setRequestAttributes(attributes, true);
}
HttpServletRequest request = RequestHolder.getRequestHolder();
super.execute(() -> {
if(null != request){
RequestHolder.setRequestHolder(request);
}
try {
command.run();
} catch (Exception e) {
throw new RuntimeException("异步线程执行失败", e);
}
finally {
RequestHolder.removeRequestHolder();
}
});
}
}
\ No newline at end of file
## Redis连接配置
spring.data.redis.host=172.16.19.99
spring.data.redis.port=6379
spring.data.redis.password=joshine.dev
spring.data.redis.database=7
spring.data.redis.timeout=5000ms
spring.data.redis.connect-timeout=10000ms
## Lettuce连接池配置
spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.max-active=16
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=4
spring.data.redis.lettuce.pool.max-wait=5000ms
......@@ -10,7 +10,7 @@ server.tomcat.threads.min-spare=100
## Spring配置
spring.application.name=custom-server
spring.profiles.active=datasource
spring.profiles.active=datasource,redis
spring.main.allow-circular-references=true
## Jackson配置
......
......@@ -70,6 +70,16 @@
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce连接池需要commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment