feat(mq-starter-idempotent-redis): 新建 driver 中立去重模块,迁入 RedisMqIdempotentStore 及其测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 10:04:10 +08:00
parent f4793b10c0
commit c2a31c9339
5 changed files with 172 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
package com.njcn.mq.idempotent.redis;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class RedisMqIdempotentStoreIT {
@Test
void stateMachine_firstAcquire_thenBlocked_failReopens_successBlocks() {
StringRedisTemplate redis = newRedisOrSkip();
RedisMqIdempotentStore store = new RedisMqIdempotentStore(redis, "mq:idem:it:", 60, 300, 300);
String key = "k_" + System.nanoTime();
assertTrue(store.tryAcquire(key), "首次应放行并占用 processing");
assertFalse(store.tryAcquire(key), "processing 中应挡回");
store.markFail(key);
assertTrue(store.tryAcquire(key), "上次 fail 后应允许重新消费");
store.markSuccess(key);
assertFalse(store.tryAcquire(key), "success 后应挡回,避免重复消费");
}
private static StringRedisTemplate newRedisOrSkip() {
try {
LettuceConnectionFactory cf = new LettuceConnectionFactory("localhost", 6379);
cf.afterPropertiesSet();
StringRedisTemplate t = new StringRedisTemplate(cf);
t.afterPropertiesSet();
t.hasKey("ping");
return t;
} catch (Exception e) { assumeTrue(false, "no local redis, skip"); return null; }
}
}

View File

@@ -0,0 +1,34 @@
package com.njcn.mq.idempotent.redis;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* {@link RedisMqIdempotentStore#isCompleted(String)} 纯单测(mock StringRedisTemplate不依赖真实 Redis
*
* <p>isCompleted 必须仅在 key 值为 {@code success} 时返回 trueprocessing / 无记录 一律 false
* 否则消费者崩溃后残留的 processing 标记会被 driver 当成"已完成"而 ACK造成静默丢消息。
*/
class RedisMqIdempotentStoreTest {
@Test
void isCompleted_trueOnlyForSuccessValue() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> ops = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(ops);
RedisMqIdempotentStore store = new RedisMqIdempotentStore(redis, "mq:idem:", 60, 300, 300);
when(ops.get("mq:idem:kSucc")).thenReturn("success");
when(ops.get("mq:idem:kProc")).thenReturn("processing");
when(ops.get("mq:idem:kNone")).thenReturn(null);
assertTrue(store.isCompleted("kSucc"), "success 应视为已完成driver 可 ACK");
assertFalse(store.isCompleted("kProc"), "processing 不可视为完成,否则崩溃残留会被 ACK 丢消息");
assertFalse(store.isCompleted("kNone"), "无记录(标记已过期)不可视为完成");
}
}