feat(MCA): TASK-005 消息幂等性工具类 [AC-MCA-11-IDEMPOTENT]
- 创建 IdempotentHelper 工具类 - 使用 Redis SETNX 实现 - TTL 1 小时 - 单元测试覆盖
This commit is contained in:
parent
6da295d571
commit
d3b696d9bb
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue