diff --git a/continew-starter-idempotent/pom.xml b/continew-starter-idempotent/pom.xml new file mode 100644 index 00000000..e29bcb4f --- /dev/null +++ b/continew-starter-idempotent/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + top.continew + continew-starter + ${revision} + + + continew-starter-idempotent + ContiNew Starter 幂等模块 + + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-configuration-processor + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-data-redis + true + + + + org.springframework.boot + spring-boot-starter-web + + + jakarta.servlet + jakarta.servlet-api + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/annotation/Idempotent.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/annotation/Idempotent.java new file mode 100644 index 00000000..850ba912 --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/annotation/Idempotent.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.annotation; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + +/** + * 定义幂等注解 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:26 + * @Package top.continew.starter.idempotent.annotation.Idempotent + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Idempotent { + + // 幂等key前缀 + String prefix() default "idempotent:"; + + // key的过期时间 + long timeout() default 0; + + // 时间单位 + TimeUnit timeUnit() default TimeUnit.SECONDS; + + // 失败时的提示信息 + String message() default "请勿重复操作"; +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/aspect/IdempotentAspect.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/aspect/IdempotentAspect.java new file mode 100644 index 00000000..72dcb16a --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/aspect/IdempotentAspect.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.aspect; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import top.continew.starter.idempotent.annotation.Idempotent; +import top.continew.starter.idempotent.service.IdempotentService; +import top.continew.starter.idempotent.util.IdempotentKeyGenerator; + +/** + * 注解切面 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:27 + * @Package top.continew.starter.idempotent.aspect.IdempotentAspect + */ +@Aspect +public class IdempotentAspect { + + private final IdempotentService idempotentService; + + public IdempotentAspect(IdempotentService idempotentService) { + this.idempotentService = idempotentService; + } + + /** + * AspectJ 环绕通知 + * + * @param joinPoint 切点 + * @param idempotent 注解 + * @return + * @throws Throwable + */ + @Around("@annotation(idempotent)") + public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { + HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()) + .getRequest(); + String key = IdempotentKeyGenerator.generateKey(idempotent.prefix(), joinPoint, request); + + try { + if (!idempotentService.checkAndLock(key, idempotent.timeout())) { + throw new RuntimeException(idempotent.message()); + } + return joinPoint.proceed(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + idempotentService.unlock(key); + } + } +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentAutoConfiguration.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentAutoConfiguration.java new file mode 100644 index 00000000..d00b1524 --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import top.continew.starter.idempotent.aspect.IdempotentAspect; +import top.continew.starter.idempotent.service.IdempotentService; +import top.continew.starter.idempotent.service.impl.MemoryIdempotentServiceImpl; +import top.continew.starter.idempotent.service.impl.RedisIdempotentServiceImpl; + +/** + * 引用配置:暂定默认内存实现,扫描到启用redis 使用redis实现 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 20:03 + * @Package top.continew.starter.idempotent.config.IdempotentAutoConfiguration + */ +@Configuration +@EnableConfigurationProperties(IdempotentProperties.class) +public class IdempotentAutoConfiguration { + + @Autowired + private IdempotentService idempotentService; + + private final IdempotentProperties properties; + + public IdempotentAutoConfiguration(IdempotentProperties properties) { + this.properties = properties; + } + + @Bean + public IdempotentAspect idempotentAspect(IdempotentService idempotentService) { + return new IdempotentAspect(idempotentService); + } + + @Bean(name = "redisIdempotentService") + @ConditionalOnBean(StringRedisTemplate.class) + public IdempotentService redisIdempotentService(StringRedisTemplate redisTemplate) { + return new RedisIdempotentServiceImpl(redisTemplate, properties.getRedisTimeout()); + } + + @Bean + @ConditionalOnMissingBean(IdempotentService.class) + public IdempotentService memoryIdempotentService() { + return new MemoryIdempotentServiceImpl(properties.getCleanInterval()); + } + + @Bean + public IdempotentProperties idempotentProperties() { + return new IdempotentProperties(); + } +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentProperties.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentProperties.java new file mode 100644 index 00000000..d4b7701f --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/IdempotentProperties.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 属性配置类 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:27 + * @Package top.continew.starter.idempotent.config.IdempotentProperties + */ +@ConfigurationProperties(prefix = "idempotent") +public class IdempotentProperties { + + // 内存实现清理过期 key 的间隔(毫秒)默认5分钟 + private long cleanInterval = 300000; + + // redis实现过期 key 的间隔(毫秒)默认5分钟 + private long redisTimeout = 300000; + + public long getCleanInterval() { + return cleanInterval; + } + + public void setCleanInterval(long cleanInterval) { + this.cleanInterval = cleanInterval; + } + + public long getRedisTimeout() { + return redisTimeout; + } + + public void setRedisTimeout(long redisTimeout) { + this.redisTimeout = redisTimeout; + } +} diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/MemoryIdempotentSchedulingConfig.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/MemoryIdempotentSchedulingConfig.java new file mode 100644 index 00000000..f4970c4a --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/config/MemoryIdempotentSchedulingConfig.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import top.continew.starter.idempotent.service.IdempotentService; +import top.continew.starter.idempotent.service.impl.MemoryIdempotentServiceImpl; + +/** + * 内存定时任务配置类 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 22:26 + * @Package top.continew.starter.idempotent.config.MemoryIdempotentSchedulingConfig + */ +@Configuration +@ConditionalOnMissingBean(name = "redisIdempotentService") // 当 Redis Bean 不存在时启用 +@EnableScheduling +public class MemoryIdempotentSchedulingConfig { + + @Autowired + private IdempotentService idempotentService; + + @Scheduled(fixedRateString = "${idempotent.clean-interval:60000}") + public void cleanExpiredKeys() { + if (idempotentService instanceof MemoryIdempotentServiceImpl) { + ((MemoryIdempotentServiceImpl)idempotentService).cleanExpiredKeys(); + } + } +} diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/IdempotentService.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/IdempotentService.java new file mode 100644 index 00000000..2fc6e089 --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/IdempotentService.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.service; + +/** + * 服务接口 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:48 + * @Package top.continew.starter.idempotent.service.IdempotentService + */ +public interface IdempotentService { + + /** + * 检验是否存在 + * + * @param key 幂等key + * @param timeout 超时时间 + * @return + */ + boolean checkAndLock(String key, long timeout); + + /** + * 释放对应key + * + * @param key 幂等key + */ + void unlock(String key); +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/MemoryIdempotentServiceImpl.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/MemoryIdempotentServiceImpl.java new file mode 100644 index 00000000..2b8108c9 --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/MemoryIdempotentServiceImpl.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.service.impl; + +import top.continew.starter.idempotent.service.IdempotentService; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * 内存实现幂等 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:49 + * @Package top.continew.starter.idempotent.service.impl.MemoryIdempotentServiceImpl + */ +public class MemoryIdempotentServiceImpl implements IdempotentService { + + private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); + private final long cleanInterval; + + public MemoryIdempotentServiceImpl(long cleanInterval) { + this.cleanInterval = cleanInterval; + } + + @Override + public boolean checkAndLock(String key, long timeout) { + if (key == null || key.isEmpty()) { + throw new IllegalArgumentException("Key cannot be null or empty"); + } + + long currentTime = System.currentTimeMillis(); + Long existingExpireTime = lockMap.get(key); + + // 如果 key 已存在且未过期,返回 false + if (existingExpireTime != null && currentTime < existingExpireTime) { + return false; + } + + // 设置新的过期时间并放入 map + long effectiveTimeout = timeout > 0 ? timeout : cleanInterval; + long newExpireTime = currentTime + TimeUnit.SECONDS.toMillis(effectiveTimeout); + lockMap.put(key, newExpireTime); + return true; + } + + @Override + public void unlock(String key) { + if (key != null && !key.isEmpty()) { + lockMap.remove(key); + } + } + + // 清理过期 key 的方法 + public void cleanExpiredKeys() { + long currentTime = System.currentTimeMillis(); + lockMap.entrySet().removeIf(entry -> currentTime >= entry.getValue()); + } +} diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/RedisIdempotentServiceImpl.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/RedisIdempotentServiceImpl.java new file mode 100644 index 00000000..bb20c64d --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/service/impl/RedisIdempotentServiceImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.service.impl; + +import org.springframework.data.redis.core.StringRedisTemplate; +import top.continew.starter.idempotent.service.IdempotentService; + +import java.util.concurrent.TimeUnit; + +/** + * redis实现幂等 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 19:49 + * @Package top.continew.starter.idempotent.service.impl.RedisIdempotentServiceImpl + */ + +public class RedisIdempotentServiceImpl implements IdempotentService { + + private final StringRedisTemplate redisTemplate; + private final long redisTimeout; + + public RedisIdempotentServiceImpl(StringRedisTemplate redisTemplate, long redisTimeout) { + this.redisTemplate = redisTemplate; + this.redisTimeout = redisTimeout; + } + + @Override + public boolean checkAndLock(String key, long timeout) { + long effectiveTimeout = timeout > 0 ? timeout : redisTimeout; + Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", effectiveTimeout, TimeUnit.SECONDS); + return success != null && success; + } + + @Override + public void unlock(String key) { + redisTemplate.delete(key); + } +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/util/IdempotentKeyGenerator.java b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/util/IdempotentKeyGenerator.java new file mode 100644 index 00000000..3c303a54 --- /dev/null +++ b/continew-starter-idempotent/src/main/java/top/continew/starter/idempotent/util/IdempotentKeyGenerator.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.starter.idempotent.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.springframework.util.DigestUtils; + +import java.util.Arrays; + +/** + * 定义幂等key工具类 + * + * @version 1.0 + * @Author loach + * @Date 2025-03-07 20:13 + * @Package top.continew.starter.idempotent.util.IdempotentKeyGenerator + */ +public class IdempotentKeyGenerator { + + /** + * 创建key + * + * @param prefix 幂等key前缀 + * @param joinPoint 切面参数 + * @param request 请求头 + * @return + */ + public static String generateKey(String prefix, JoinPoint joinPoint, HttpServletRequest request) { + // 可选使用header中的token + String token = request.getHeader("Authorization"); + if (token == null || token.isEmpty()) { + token = request.getParameter("Authorization"); + } + + String methodSignature = joinPoint.getSignature().toString(); + String argsStr = Arrays.toString(joinPoint.getArgs()); + + // 如果没有token,只使用方法签名和参数生成key + String rawKey = prefix + methodSignature + argsStr + (token != null ? token : ""); + return DigestUtils.md5DigestAsHex(rawKey.getBytes()); + } +} \ No newline at end of file diff --git a/continew-starter-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/continew-starter-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..b0505757 --- /dev/null +++ b/continew-starter-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +top.continew.starter.idempotent.config.IdempotentAutoConfiguration +top.continew.starter.idempotent.config.MemoryIdempotentSchedulingConfig \ No newline at end of file diff --git a/pom.xml b/pom.xml index cd3a2843..bac5220c 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,7 @@ continew-starter-auth continew-starter-messaging continew-starter-extension + continew-starter-idempotent