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