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 @@
+
+
+ * 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
+ * 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 @@