diff --git a/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
new file mode 100644
index 0000000..470d26f
--- /dev/null
+++ b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
@@ -0,0 +1,116 @@
+package com.njcn.middle.cache.template;
+
+import com.alibaba.fastjson.TypeReference;
+import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
+import com.njcn.middle.cache.support.CacheKeyBuilder;
+import com.njcn.middle.cache.support.CacheValueCodec;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+/**
+ * Redis 缓存增强模板。
+ *
+ *
底层 {@link StringRedisTemplate} 存字符串,fastjson 序列化。本类(Task 4)实现读写基础;
+ * {@code getOrLoad} 三防(穿透/雪崩/击穿)由 Task 5 追加。
+ */
+@Slf4j
+public class RedisCacheEnhanceTemplate {
+
+ private final StringRedisTemplate redis;
+ private final CacheKeyBuilder keyBuilder;
+ private final CacheValueCodec codec;
+ private final RedisCacheProperties props;
+
+ public RedisCacheEnhanceTemplate(StringRedisTemplate redis, CacheKeyBuilder keyBuilder,
+ CacheValueCodec codec, RedisCacheProperties props) {
+ this.redis = redis;
+ this.keyBuilder = keyBuilder;
+ this.codec = codec;
+ this.props = props;
+ }
+
+ // ---- 写 ----
+
+ public void set(String key, Object value) {
+ set(key, value, props.getDefaultTtlSeconds());
+ }
+
+ public void set(String key, Object value, long ttlSeconds) {
+ redis.opsForValue().set(keyBuilder.build(key), codec.encode(value),
+ applyJitter(ttlSeconds), TimeUnit.SECONDS);
+ }
+
+ // ---- 读 ----
+
+ public T get(String key, Class type) {
+ return doGet(key, raw -> codec.decode(raw, type));
+ }
+
+ public T get(String key, TypeReference type) {
+ return doGet(key, raw -> codec.decode(raw, type));
+ }
+
+ private T doGet(String key, Function decoder) {
+ String dataKey = keyBuilder.build(key);
+ String raw = redis.opsForValue().get(dataKey);
+ if (raw == null || codec.isNullSentinel(raw)) {
+ return null;
+ }
+ T value;
+ try {
+ value = decoder.apply(raw);
+ } catch (RuntimeException e) {
+ throw new IllegalStateException("cache decode failed, key=" + dataKey, e);
+ }
+ slidingTouch(dataKey);
+ return value;
+ }
+
+ // ---- 删 / 判存 ----
+
+ public boolean delete(String key) {
+ return Boolean.TRUE.equals(redis.delete(keyBuilder.build(key)));
+ }
+
+ public boolean exists(String key) {
+ return Boolean.TRUE.equals(redis.hasKey(keyBuilder.build(key)));
+ }
+
+ // ---- 续期 ----
+
+ public boolean expire(String key, long ttlSeconds) {
+ return Boolean.TRUE.equals(
+ redis.expire(keyBuilder.build(key), applyJitter(ttlSeconds), TimeUnit.SECONDS));
+ }
+
+ // ---- 内部 ----
+
+ /** 滑动过期:开启时对已存在的真实值 key 续期(直接对 dataKey 操作,失败忽略)。 */
+ private void slidingTouch(String dataKey) {
+ if (!props.isSlidingExpireEnabled()) {
+ return;
+ }
+ try {
+ redis.expire(dataKey, applyJitter(props.getDefaultTtlSeconds()), TimeUnit.SECONDS);
+ } catch (RuntimeException e) {
+ log.warn("[redis-cache] sliding expire failed key={}", dataKey, e);
+ }
+ }
+
+ /** 在原 TTL 上叠加 [0, ttl*ratio] 的随机抖动(只增不减);ratio<=0 或 ttl<=0 时原样返回。 */
+ long applyJitter(long ttlSeconds) {
+ double ratio = props.getJitterRatio();
+ if (ratio <= 0 || ttlSeconds <= 0) {
+ return ttlSeconds;
+ }
+ long bound = (long) (ttlSeconds * ratio);
+ if (bound <= 0) {
+ return ttlSeconds;
+ }
+ return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1);
+ }
+}
diff --git a/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
new file mode 100644
index 0000000..313b76c
--- /dev/null
+++ b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
@@ -0,0 +1,136 @@
+package com.njcn.middle.cache.template;
+
+import com.alibaba.fastjson.JSON;
+import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
+import com.njcn.middle.cache.constant.CacheConstant;
+import com.njcn.middle.cache.support.CacheKeyBuilder;
+import com.njcn.middle.cache.support.CacheValueCodec;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class RedisCacheEnhanceTemplateTest {
+
+ @Mock
+ StringRedisTemplate redis;
+ @Mock
+ ValueOperations valueOps;
+
+ CacheValueCodec codec = new CacheValueCodec();
+ CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, "");
+ RedisCacheProperties props = new RedisCacheProperties();
+ RedisCacheEnhanceTemplate template;
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class User {
+ private Long id;
+ private String name;
+ }
+
+ @BeforeEach
+ void init() {
+ MockitoAnnotations.initMocks(this);
+ lenient().when(redis.opsForValue()).thenReturn(valueOps);
+ template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props);
+ }
+
+ @Test
+ void setSerializesWithExactTtlWhenNoJitter() {
+ props.setJitterRatio(0);
+ User u = new User(1L, "a");
+ template.set("user:1", u, 100);
+ verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void getHitDeserializes() {
+ when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
+ User got = template.get("user:1", User.class);
+ assertEquals("a", got.getName());
+ }
+
+ @Test
+ void getMissReturnsNull() {
+ when(valueOps.get("cache:user:1")).thenReturn(null);
+ assertNull(template.get("user:1", User.class));
+ }
+
+ @Test
+ void getNullSentinelReturnsNull() {
+ when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
+ assertNull(template.get("user:1", User.class));
+ }
+
+ @Test
+ void getDirtyDataThrowsIllegalState() {
+ when(valueOps.get("cache:user:1")).thenReturn("{not-json");
+ assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class));
+ }
+
+ @Test
+ void expireDelegatesWithJitterTtl() {
+ props.setJitterRatio(0);
+ when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true);
+ assertTrue(template.expire("user:1", 200));
+ verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void applyJitterWithinBounds() {
+ props.setJitterRatio(0.1);
+ long bound = (long) (100 * 0.1);
+ for (int i = 0; i < 50; i++) {
+ long ttl = template.applyJitter(100);
+ assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl);
+ }
+ props.setJitterRatio(0);
+ assertEquals(100, template.applyJitter(100));
+ }
+
+ @Test
+ void slidingOffDoesNotRenewOnGet() {
+ when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
+ template.get("user:1", User.class);
+ verify(redis, never()).expire(any(), anyLong(), any());
+ }
+
+ @Test
+ void slidingOnRenewsRealValueOnGet() {
+ props.setSlidingExpireEnabled(true);
+ props.setJitterRatio(0);
+ when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
+ when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
+ template.get("user:1", User.class);
+ verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Test
+ void slidingOnDoesNotRenewNullSentinel() {
+ props.setSlidingExpireEnabled(true);
+ when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
+ assertNull(template.get("user:1", User.class));
+ verify(redis, never()).expire(any(), anyLong(), any());
+ }
+}