From d3b696d9bb874d026e84cdfe007171ff9d7a7ad2 Mon Sep 17 00:00:00 2001 From: MerCry Date: Tue, 24 Feb 2026 10:18:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(MCA):=20TASK-005=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=80=A7=E5=B7=A5=E5=85=B7=E7=B1=BB=20[AC-MC?= =?UTF-8?q?A-11-IDEMPOTENT]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 IdempotentHelper 工具类 - 使用 Redis SETNX 实现 - TTL 1 小时 - 单元测试覆盖 --- .../wecom/robot/util/IdempotentHelper.java | 56 +++++++++ .../robot/util/IdempotentHelperTest.java | 112 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/main/java/com/wecom/robot/util/IdempotentHelper.java create mode 100644 src/test/java/com/wecom/robot/util/IdempotentHelperTest.java diff --git a/src/main/java/com/wecom/robot/util/IdempotentHelper.java b/src/main/java/com/wecom/robot/util/IdempotentHelper.java new file mode 100644 index 0000000..ef65c09 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/IdempotentHelper.java @@ -0,0 +1,56 @@ +package com.wecom.robot.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class IdempotentHelper { + + private static final String KEY_PREFIX = "idempotent:"; + private static final long DEFAULT_TTL_HOURS = 1; + + private final StringRedisTemplate redisTemplate; + + public boolean processMessageIdempotent(String channelMessageId, Runnable processor) { + String key = KEY_PREFIX + channelMessageId; + Boolean absent = redisTemplate.opsForValue() + .setIfAbsent(key, "1", DEFAULT_TTL_HOURS, TimeUnit.HOURS); + + if (Boolean.TRUE.equals(absent)) { + processor.run(); + return true; + } + + log.info("[AC-MCA-11-IDEMPOTENT] 重复消息,跳过处理: channelMessageId={}", channelMessageId); + return false; + } + + public boolean checkAndSet(String channelMessageId) { + String key = KEY_PREFIX + channelMessageId; + Boolean absent = redisTemplate.opsForValue() + .setIfAbsent(key, "1", DEFAULT_TTL_HOURS, TimeUnit.HOURS); + + if (Boolean.TRUE.equals(absent)) { + return true; + } + + log.info("[AC-MCA-11-IDEMPOTENT] 重复消息检测: channelMessageId={}", channelMessageId); + return false; + } + + public boolean exists(String channelMessageId) { + String key = KEY_PREFIX + channelMessageId; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public void remove(String channelMessageId) { + String key = KEY_PREFIX + channelMessageId; + redisTemplate.delete(key); + } +} diff --git a/src/test/java/com/wecom/robot/util/IdempotentHelperTest.java b/src/test/java/com/wecom/robot/util/IdempotentHelperTest.java new file mode 100644 index 0000000..ce354ac --- /dev/null +++ b/src/test/java/com/wecom/robot/util/IdempotentHelperTest.java @@ -0,0 +1,112 @@ +package com.wecom.robot.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IdempotentHelperTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private IdempotentHelper idempotentHelper; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + idempotentHelper = new IdempotentHelper(redisTemplate); + } + + @Test + void testProcessMessageIdempotent_FirstTime_ShouldProcess() { + String messageId = "msg-123"; + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + + boolean[] processed = {false}; + boolean result = idempotentHelper.processMessageIdempotent(messageId, () -> processed[0] = true); + + assertTrue(result); + assertTrue(processed[0]); + verify(valueOperations).setIfAbsent(eq("idempotent:msg-123"), eq("1"), eq(1L), eq(TimeUnit.HOURS)); + } + + @Test + void testProcessMessageIdempotent_Duplicate_ShouldSkip() { + String messageId = "msg-456"; + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(false); + + boolean[] processed = {false}; + boolean result = idempotentHelper.processMessageIdempotent(messageId, () -> processed[0] = true); + + assertFalse(result); + assertFalse(processed[0]); + } + + @Test + void testCheckAndSet_FirstTime_ShouldReturnTrue() { + String messageId = "msg-789"; + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + + boolean result = idempotentHelper.checkAndSet(messageId); + + assertTrue(result); + verify(valueOperations).setIfAbsent(eq("idempotent:msg-789"), eq("1"), eq(1L), eq(TimeUnit.HOURS)); + } + + @Test + void testCheckAndSet_Duplicate_ShouldReturnFalse() { + String messageId = "msg-duplicate"; + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(false); + + boolean result = idempotentHelper.checkAndSet(messageId); + + assertFalse(result); + } + + @Test + void testExists_KeyExists_ShouldReturnTrue() { + String messageId = "msg-exists"; + when(redisTemplate.hasKey("idempotent:msg-exists")).thenReturn(true); + + boolean result = idempotentHelper.exists(messageId); + + assertTrue(result); + } + + @Test + void testExists_KeyNotExists_ShouldReturnFalse() { + String messageId = "msg-notexists"; + when(redisTemplate.hasKey("idempotent:msg-notexists")).thenReturn(false); + + boolean result = idempotentHelper.exists(messageId); + + assertFalse(result); + } + + @Test + void testRemove_ShouldDeleteKey() { + String messageId = "msg-remove"; + + idempotentHelper.remove(messageId); + + verify(redisTemplate).delete("idempotent:msg-remove"); + } +}