diff --git a/docs/superpowers/plans/2026-06-25-redis-cache.md b/docs/superpowers/plans/2026-06-25-redis-cache.md
new file mode 100644
index 0000000..c5bc99a
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-25-redis-cache.md
@@ -0,0 +1,1796 @@
+# Redis 缓存增强模板 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在现有 `redis-stream-springboot-starter` 内新增编程式缓存模板 `RedisCacheEnhanceTemplate`,把 TTL、序列化、穿透/雪崩/击穿三防、显式 `expire` 与可选滑动过期全部封装进去。
+
+**Architecture:** 新增独立包 `com.njcn.middle.cache`(与 `stream` 平行、零耦合)。底层复用 `StringRedisTemplate` 存字符串、fastjson 序列化。`getOrLoad` 内编排三防:空值标记(穿透)、TTL 抖动(雪崩)、`SET NX PX` 互斥锁 + Lua 安全释放 + double-check + 超时降级(击穿)。独立 `RedisCacheAutoConfiguration` 追加进 `spring.factories`。
+
+**Tech Stack:** JDK 8、Spring Boot 2.3.12、spring-data-redis 2.3.x(Lettuce)、fastjson 1.2.83、hutool 5.7.9、lombok;测试 JUnit 5 + Mockito(均来自 `spring-boot-starter-test`)。
+
+设计依据:`docs/superpowers/specs/2026-06-25-redis-cache-design.md`。
+
+## Global Constraints
+
+- **JDK 8 / Spring Boot 2.3.12 / spring-data-redis 2.3.x**;**不新增任何第三方依赖**(只用 `StringRedisTemplate` / fastjson / hutool / lombok)。
+- 全部新增代码在 **`com.njcn.middle.cache`** 包下;**严禁修改 `com.njcn.middle.stream` 任何现有类与测试**(唯一允许改的现存文件是 `spring.factories` 与 `README.md`)。
+- **构建/测试命令(mvn 不在 PATH,必须用全路径,且不要带 `-q` 或 `-DskipTests`)**:
+ - 全量:`C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml`
+ - 单类:在上面基础上加 `-Dtest=类名`(放在 `test` 之前),例如 `... -Dmaven.repo.local=... -Dtest=CacheKeyBuilderTest test -f pom.xml`
+- 集成测试(`com.njcn.middle.cache.integration` 包)需真实 Redis,默认连 `127.0.0.1:6379`,**探测不到自动 skip(不 fail)**;指向其它 Redis 追加 `-Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…`。
+- **提交时只 `git add` 本任务明确列出的文件,严禁 `git add .` / `git add -A`** —— 工作区存在一处与本功能无关的未提交改动 `M README.md`,绝不可裹挟进提交。
+- 单元测试不依赖 Redis(用 Mockito mock `StringRedisTemplate`)。
+- 当前分支:`feat/redis-cache`(spec 已提交于 `ec2a8da`)。
+
+---
+
+## File Structure
+
+主代码(`src/main/java/com/njcn/middle/cache/`):
+
+| 文件 | 职责 |
+|---|---|
+| `constant/CacheConstant.java` | 常量:空值标记 `NULL_SENTINEL`、锁后缀 `LOCK_SUFFIX` |
+| `support/CacheKeyBuilder.java` | 业务 key → 实际 key(前缀 + 环境隔离),派生锁 key |
+| `support/CacheValueCodec.java` | 对象 ↔ 字符串(fastjson),空值标记判定 |
+| `autoconfig/RedisCacheProperties.java` | 绑定 `redis-cache.*` |
+| `template/RedisCacheEnhanceTemplate.java` | 核心模板:get/set/delete/exists/expire/getOrLoad |
+| `autoconfig/RedisCacheAutoConfiguration.java` | 自动装配 |
+| `src/main/resources/META-INF/spring.factories` | 追加注册(修改) |
+
+测试(`src/test/java/com/njcn/middle/cache/`):`CacheKeyBuilderTest`、`CacheValueCodecTest`、`RedisCachePropertiesTest`、`RedisCacheEnhanceTemplateTest`、`autoconfig/RedisCacheAutoConfigurationTest`、`integration/{RedisAvailableCondition, CacheRoundTripTest, CachePenetrationTest, CacheExpireSlidingTest, CacheBreakdownConcurrencyTest}`。
+
+任务顺序:1 常量+Key → 2 Codec → 3 Properties → 4 模板读写 → 5 模板 getOrLoad → 6 自动装配 → 7 集成 RoundTrip → 8 集成穿透 → 9 集成 expire/滑动 → 10 集成击穿并发 → 11 README + 全量回归。
+
+---
+
+### Task 1: 常量 + Key 构造器
+
+**Files:**
+- Create: `src/main/java/com/njcn/middle/cache/constant/CacheConstant.java`
+- Create: `src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java`
+- Test: `src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java`
+
+**Interfaces:**
+- Produces:
+ - `CacheConstant.NULL_SENTINEL`(String)、`CacheConstant.LOCK_SUFFIX`(String)
+ - `new CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env)`
+ - `String CacheKeyBuilder.build(String bizKey)`、`String CacheKeyBuilder.buildLock(String bizKey)`
+
+- [ ] **Step 1: 写失败测试**
+
+`src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java`
+```java
+package com.njcn.middle.cache.support;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class CacheKeyBuilderTest {
+
+ @Test
+ void buildWithPrefixOnly() {
+ CacheKeyBuilder kb = new CacheKeyBuilder("cache:", false, "");
+ assertEquals("cache:user:1", kb.build("user:1"));
+ }
+
+ @Test
+ void buildWithEnvIsolation() {
+ CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, "prod");
+ assertEquals("cache:prod:user:1", kb.build("user:1"));
+ }
+
+ @Test
+ void envIsolationButBlankEnvFallsBack() {
+ CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, "");
+ assertEquals("cache:user:1", kb.build("user:1"));
+ }
+
+ @Test
+ void buildLockDerivesFromDataKey() {
+ assertEquals("cache:user:1:__lock__",
+ new CacheKeyBuilder("cache:", false, "").buildLock("user:1"));
+ assertEquals("cache:prod:user:1:__lock__",
+ new CacheKeyBuilder("cache:", true, "prod").buildLock("user:1"));
+ }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml`
+Expected: 编译失败(`CacheKeyBuilder` / `CacheConstant` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+`src/main/java/com/njcn/middle/cache/constant/CacheConstant.java`
+```java
+package com.njcn.middle.cache.constant;
+
+/**
+ * Redis 缓存相关常量。
+ */
+public final class CacheConstant {
+
+ private CacheConstant() {
+ }
+
+ /**
+ * 空值标记:回源结果为 null 时写入此值(穿透防护)。
+ * 内含 NUL 控制字符(Java unicode 转义书写),fastjson 序列化任何对象都不会产出含 NUL 的裸文本,故无冲突。
+ */
+ public static final String NULL_SENTINEL = "\u0000NULL\u0000";
+
+ /** 击穿互斥锁 key 后缀(由数据 key 派生)。 */
+ public static final String LOCK_SUFFIX = ":__lock__";
+}
+```
+
+`src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java`
+```java
+package com.njcn.middle.cache.support;
+
+import cn.hutool.core.util.StrUtil;
+import com.njcn.middle.cache.constant.CacheConstant;
+
+/**
+ * 缓存 key 构造器:统一前缀 +(可选)环境隔离段。
+ *
+ *
基础:{@code keyPrefix + bizKey};开启隔离且 env 非空:{@code keyPrefix + env + ":" + bizKey}。
+ * 锁 key 由数据 key 派生,保证数据与其锁落在同一隔离域。
+ */
+public class CacheKeyBuilder {
+
+ private final String keyPrefix;
+ private final boolean envIsolation;
+ private final String env;
+
+ public CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env) {
+ this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
+ this.envIsolation = envIsolation;
+ this.env = env;
+ }
+
+ /** 业务 key → 实际数据 key。 */
+ public String build(String bizKey) {
+ if (envIsolation && StrUtil.isNotBlank(env)) {
+ return keyPrefix + env + ":" + bizKey;
+ }
+ return keyPrefix + bizKey;
+ }
+
+ /** 数据 key 派生锁 key。 */
+ public String buildLock(String bizKey) {
+ return build(bizKey) + CacheConstant.LOCK_SUFFIX;
+ }
+}
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml`
+Expected: `Tests run: 4, Failures: 0`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/constant/CacheConstant.java \
+ src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java \
+ src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java
+git commit -m "feat(cache): 缓存常量与 key 构造器(前缀 + 环境隔离 + 锁 key 派生)"
+```
+
+---
+
+### Task 2: 值编解码器 `CacheValueCodec`
+
+**Files:**
+- Create: `src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java`
+- Test: `src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java`
+
+**Interfaces:**
+- Consumes: `CacheConstant.NULL_SENTINEL`(Task 1)
+- Produces:
+ - `String CacheValueCodec.encode(Object value)` — null → `NULL_SENTINEL`,否则 `JSON.toJSONString`
+ - `boolean CacheValueCodec.isNullSentinel(String raw)`
+ - ` T CacheValueCodec.decode(String raw, Class type)`
+ - ` T CacheValueCodec.decode(String raw, com.alibaba.fastjson.TypeReference type)`
+ - 解码失败抛 fastjson 的 `RuntimeException`(由调用方决定包装)
+
+- [ ] **Step 1: 写失败测试**
+
+`src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java`
+```java
+package com.njcn.middle.cache.support;
+
+import com.alibaba.fastjson.TypeReference;
+import com.njcn.middle.cache.constant.CacheConstant;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CacheValueCodecTest {
+
+ private final CacheValueCodec codec = new CacheValueCodec();
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class Foo {
+ private Long id;
+ private String name;
+ }
+
+ @Test
+ void encodeNullReturnsSentinel() {
+ assertEquals(CacheConstant.NULL_SENTINEL, codec.encode(null));
+ assertTrue(codec.isNullSentinel(codec.encode(null)));
+ assertFalse(codec.isNullSentinel("{\"id\":1}"));
+ }
+
+ @Test
+ void encodeDecodePojoRoundTrip() {
+ Foo foo = new Foo(1L, "alice");
+ String raw = codec.encode(foo);
+ Foo back = codec.decode(raw, Foo.class);
+ assertEquals(foo, back);
+ }
+
+ @Test
+ void encodeDecodeStringRoundTrip() {
+ String raw = codec.encode("abc");
+ assertEquals("abc", codec.decode(raw, String.class));
+ }
+
+ @Test
+ void decodeGenericWithTypeReference() {
+ List list = Arrays.asList(1, 2, 3);
+ String raw = codec.encode(list);
+ List back = codec.decode(raw, new TypeReference>() {
+ });
+ assertEquals(list, back);
+ }
+
+ @Test
+ void decodeDirtyStringThrows() {
+ assertThrows(RuntimeException.class, () -> codec.decode("{not-json", Foo.class));
+ }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml`
+Expected: 编译失败(`CacheValueCodec` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+`src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java`
+```java
+package com.njcn.middle.cache.support;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import com.njcn.middle.cache.constant.CacheConstant;
+
+/**
+ * 缓存值编解码:对象 ↔ 字符串(fastjson),并承载空值标记。
+ *
+ * 解码失败直接抛 fastjson 异常({@link RuntimeException}),由调用方决定显式暴露(public get)
+ * 还是当未命中降级(getOrLoad 内部)。
+ */
+public class CacheValueCodec {
+
+ /** 对象 → 字符串:null → 空值标记;否则 fastjson 序列化。 */
+ public String encode(Object value) {
+ if (value == null) {
+ return CacheConstant.NULL_SENTINEL;
+ }
+ return JSON.toJSONString(value);
+ }
+
+ /** 是否空值标记。 */
+ public boolean isNullSentinel(String raw) {
+ return CacheConstant.NULL_SENTINEL.equals(raw);
+ }
+
+ /** 字符串 → 对象(Class)。 */
+ public T decode(String raw, Class type) {
+ return JSON.parseObject(raw, type);
+ }
+
+ /** 字符串 → 对象(TypeReference,支持泛型)。 */
+ public T decode(String raw, TypeReference type) {
+ return JSON.parseObject(raw, type);
+ }
+}
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml`
+Expected: `Tests run: 5, Failures: 0`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java \
+ src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java
+git commit -m "feat(cache): 值编解码器(fastjson 序列化 + 空值标记)"
+```
+
+---
+
+### Task 3: 配置属性 `RedisCacheProperties`
+
+**Files:**
+- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java`
+- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java`
+
+**Interfaces:**
+- Produces(lombok `@Data` 生成 getter/setter):
+ - `String keyPrefix=cache:`、`boolean envIsolation=false`、`String env=""`
+ - `long defaultTtlSeconds=3600`、`long nullTtlSeconds=60`、`double jitterRatio=0.1`
+ - `boolean slidingExpireEnabled=false`
+ - `Breakdown breakdown`,内嵌:`boolean enabled=true`、`long lockTtlMs=10000`、`long waitTimeoutMs=3000`、`long waitIntervalMs=50`
+ - 注意 `boolean` 字段的 getter 形如 `isEnvIsolation()` / `isSlidingExpireEnabled()` / `breakdown.isEnabled()`
+
+- [ ] **Step 1: 写失败测试**
+
+`src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java`
+```java
+package com.njcn.middle.cache.autoconfig;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RedisCachePropertiesTest {
+
+ @Test
+ void defaults() {
+ RedisCacheProperties p = new RedisCacheProperties();
+ assertEquals("cache:", p.getKeyPrefix());
+ assertFalse(p.isEnvIsolation());
+ assertEquals("", p.getEnv());
+ assertEquals(3600, p.getDefaultTtlSeconds());
+ assertEquals(60, p.getNullTtlSeconds());
+ assertEquals(0.1, p.getJitterRatio());
+ assertFalse(p.isSlidingExpireEnabled());
+ assertTrue(p.getBreakdown().isEnabled());
+ assertEquals(10000, p.getBreakdown().getLockTtlMs());
+ assertEquals(3000, p.getBreakdown().getWaitTimeoutMs());
+ assertEquals(50, p.getBreakdown().getWaitIntervalMs());
+ }
+
+ @Test
+ void relaxedBinding() {
+ MockEnvironment env = new MockEnvironment()
+ .withProperty("redis-cache.default-ttl-seconds", "1800")
+ .withProperty("redis-cache.sliding-expire-enabled", "true")
+ .withProperty("redis-cache.breakdown.lock-ttl-ms", "5000")
+ .withProperty("redis-cache.breakdown.enabled", "false");
+ RedisCacheProperties p = Binder.get(env)
+ .bind("redis-cache", RedisCacheProperties.class).get();
+ assertEquals(1800, p.getDefaultTtlSeconds());
+ assertTrue(p.isSlidingExpireEnabled());
+ assertEquals(5000, p.getBreakdown().getLockTtlMs());
+ assertFalse(p.getBreakdown().isEnabled());
+ }
+}
+```
+
+> 注:`MockEnvironment` 来自 `spring-test`(已随 `spring-boot-starter-test` 引入);`ConfigurationPropertySources` import 保留以确保 `Binder.get(Environment)` 走宽松绑定。
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml`
+Expected: 编译失败(`RedisCacheProperties` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java`
+```java
+package com.njcn.middle.cache.autoconfig;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Redis 缓存配置属性,前缀 {@code redis-cache}。全部可选,有合理默认。
+ */
+@Data
+@ConfigurationProperties("redis-cache")
+public class RedisCacheProperties {
+
+ /** 统一 key 前缀。 */
+ private String keyPrefix = "cache:";
+
+ /** 是否按环境隔离 key。 */
+ private boolean envIsolation = false;
+
+ /** 环境标识(隔离开启且非空时拼入 key)。 */
+ private String env = "";
+
+ /** set/getOrLoad 不传 TTL 时的默认过期(秒)。 */
+ private long defaultTtlSeconds = 3600;
+
+ /** 空值标记 TTL(秒)——穿透防护,宜短。 */
+ private long nullTtlSeconds = 60;
+
+ /** TTL 随机抖动比例——雪崩防护;0 关闭抖动。 */
+ private double jitterRatio = 0.1;
+
+ /** 读命中真实值时自动续期(滑动过期),默认关闭。 */
+ private boolean slidingExpireEnabled = false;
+
+ private Breakdown breakdown = new Breakdown();
+
+ /** 击穿:互斥锁。 */
+ @Data
+ public static class Breakdown {
+ /** 关闭后 getOrLoad 退化为无锁直接回源。 */
+ private boolean enabled = true;
+ /** 锁持有 TTL(ms),防持锁线程崩溃死锁。 */
+ private long lockTtlMs = 10000;
+ /** 未抢到锁的线程最长等待(ms),超时降级直接回源。 */
+ private long waitTimeoutMs = 3000;
+ /** 未抢到锁时自旋重读间隔(ms)。 */
+ private long waitIntervalMs = 50;
+ }
+}
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml`
+Expected: `Tests run: 2, Failures: 0`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java \
+ src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java
+git commit -m "feat(cache): 配置属性 redis-cache.*(TTL/抖动/滑动/击穿锁)"
+```
+
+---
+
+### Task 4: 模板读写基础(set/get/delete/exists/expire + 抖动 + 滑动续期)
+
+**Files:**
+- Create: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
+- Test: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`
+
+**Interfaces:**
+- Consumes: `CacheKeyBuilder`(Task 1)、`CacheValueCodec`(Task 2)、`RedisCacheProperties`(Task 3)、`CacheConstant`
+- Produces(本任务实现这些;`getOrLoad` 由 Task 5 追加):
+ - 构造 `new RedisCacheEnhanceTemplate(StringRedisTemplate, CacheKeyBuilder, CacheValueCodec, RedisCacheProperties)`
+ - `void set(String key, Object value)` / `void set(String key, Object value, long ttlSeconds)`
+ - ` T get(String key, Class type)` / ` T get(String key, TypeReference type)`
+ - `boolean delete(String key)` / `boolean exists(String key)`
+ - `boolean expire(String key, long ttlSeconds)`
+ - 包级 `long applyJitter(long ttlSeconds)`(供测试与 Task 5 复用)
+ - 私有 `void slidingTouch(String dataKey)`、私有 ` T doGet(String key, java.util.function.Function decoder)`
+- 关键语义:`get` 命中真实值时若开启滑动则续期;`get` 反序列化失败抛 `IllegalStateException`;`set`/`expire` 的 TTL 走 `applyJitter`。
+
+- [ ] **Step 1: 写失败测试**
+
+`src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`
+```java
+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.openMocks(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());
+ }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
+Expected: 编译失败(`RedisCacheEnhanceTemplate` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+`src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
+```java
+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);
+ }
+}
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
+Expected: `Tests run: 10, Failures: 0`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \
+ src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
+git commit -m "feat(cache): 模板读写基础(set/get/delete/exists/expire + TTL 抖动 + 滑动续期)"
+```
+
+---
+
+### Task 5: 模板 `getOrLoad` 三防(穿透 + 雪崩 + 击穿互斥锁)
+
+**Files:**
+- Modify: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
+- Modify: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`(追加测试)
+
+**Interfaces:**
+- Consumes: Task 4 的全部私有/包级成员(`applyJitter`、`slidingTouch`、`codec`、`keyBuilder`、`props`、`redis`)、`CacheConstant.LOCK_SUFFIX`
+- Produces:
+ - ` T getOrLoad(String key, Class type, java.util.function.Supplier loader)`
+ - ` T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader)`
+ - ` T getOrLoad(String key, com.alibaba.fastjson.TypeReference type, long ttlSeconds, Supplier loader)`
+- 锁实现:`opsForValue().setIfAbsent(lockKey, token, Duration)` 抢锁;Lua `if get==token then del` 释放。
+
+- [ ] **Step 1: 追加失败测试**
+
+在 `RedisCacheEnhanceTemplateTest` 类中追加以下导入与测试方法(导入加到文件顶部已有 import 区):
+```java
+// 追加 import:
+import org.springframework.data.redis.core.script.RedisScript;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+```
+```java
+ @Test
+ void getOrLoadHitDoesNotCallLoader() {
+ when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
+ AtomicInteger calls = new AtomicInteger();
+ Supplier loader = () -> {
+ calls.incrementAndGet();
+ return new User(9L, "x");
+ };
+ User got = template.getOrLoad("user:1", User.class, 60, loader);
+ assertEquals("a", got.getName());
+ assertEquals(0, calls.get());
+ }
+
+ @Test
+ void getOrLoadNullSentinelHitReturnsNullNoLoader() {
+ when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
+ AtomicInteger calls = new AtomicInteger();
+ assertNull(template.getOrLoad("user:1", User.class, 60, () -> {
+ calls.incrementAndGet();
+ return new User(1L, "a");
+ }));
+ assertEquals(0, calls.get());
+ }
+
+ @Test
+ void getOrLoadMissAcquiresLockLoadsAndCaches() {
+ props.setJitterRatio(0);
+ when(valueOps.get("cache:user:1")).thenReturn(null);
+ when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
+ .thenReturn(true);
+ User loaded = new User(1L, "a");
+ User got = template.getOrLoad("user:1", User.class, 60, () -> loaded);
+ assertEquals("a", got.getName());
+ verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS);
+ // 释放锁:执行了 Lua 脚本
+ verify(redis).execute(any(RedisScript.class), anyList(), any());
+ }
+
+ @Test
+ void getOrLoadLoaderReturnsNullWritesSentinel() {
+ when(valueOps.get("cache:user:1")).thenReturn(null);
+ when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
+ .thenReturn(true);
+ assertNull(template.getOrLoad("user:1", User.class, 60, () -> null));
+ verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL,
+ props.getNullTtlSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Test
+ void getOrLoadNotLockedWaitsThenReadsValue() {
+ props.getBreakdown().setWaitIntervalMs(1);
+ props.getBreakdown().setWaitTimeoutMs(1000);
+ // 初始 tryGet 返回 null,未抢到锁,随后等待中读到值
+ when(valueOps.get("cache:user:1"))
+ .thenReturn(null)
+ .thenReturn(null)
+ .thenReturn(JSON.toJSONString(new User(1L, "a")));
+ when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
+ AtomicInteger calls = new AtomicInteger();
+ User got = template.getOrLoad("user:1", User.class, 60, () -> {
+ calls.incrementAndGet();
+ return new User(9L, "x");
+ });
+ assertEquals("a", got.getName());
+ assertEquals(0, calls.get());
+ }
+
+ @Test
+ void getOrLoadWaitTimeoutFallsBackToLoader() {
+ props.getBreakdown().setWaitIntervalMs(1);
+ props.getBreakdown().setWaitTimeoutMs(5);
+ when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中
+ when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
+ AtomicInteger calls = new AtomicInteger();
+ User got = template.getOrLoad("user:1", User.class, 60, () -> {
+ calls.incrementAndGet();
+ return new User(7L, "fallback");
+ });
+ assertEquals("fallback", got.getName());
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ void getOrLoadBreakdownDisabledLoadsWithoutLock() {
+ props.getBreakdown().setEnabled(false);
+ when(valueOps.get("cache:user:1")).thenReturn(null);
+ User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a"));
+ assertEquals("a", got.getName());
+ verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class));
+ }
+
+ @Test
+ void getOrLoadInternalDecodeFailureFallsBackToReload() {
+ // 初始读到脏数据(当未命中降级),抢到锁后回源重建
+ when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty");
+ when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
+ .thenReturn(true);
+ User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh"));
+ assertEquals("fresh", got.getName());
+ }
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
+Expected: 编译失败(`getOrLoad` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+在 `RedisCacheEnhanceTemplate` 顶部追加 import:
+```java
+import cn.hutool.core.util.IdUtil;
+import com.njcn.middle.cache.constant.CacheConstant;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.function.Supplier;
+```
+在类内(`applyJitter` 之后)追加以下成员;并在类顶部字段区加入脚本常量:
+```java
+ /** 释放锁 Lua:token 匹配才删,防误删他人锁。 */
+ private static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>(
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
+ Long.class);
+```
+```java
+ // ---- 读取或回源(三防核心)----
+
+ public T getOrLoad(String key, Class type, Supplier loader) {
+ return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader);
+ }
+
+ public T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader) {
+ return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
+ }
+
+ public T getOrLoad(String key, TypeReference type, long ttlSeconds, Supplier loader) {
+ return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
+ }
+
+ private T doGetOrLoad(String key, Function decoder, long ttlSeconds, Supplier loader) {
+ String dataKey = keyBuilder.build(key);
+
+ Lookup r = tryGet(dataKey, decoder);
+ if (r.hit) {
+ if (r.realValue) {
+ slidingTouch(dataKey);
+ }
+ return r.value;
+ }
+
+ RedisCacheProperties.Breakdown bd = props.getBreakdown();
+ if (!bd.isEnabled()) {
+ return loadAndCache(dataKey, ttlSeconds, loader);
+ }
+
+ String lockKey = dataKey + CacheConstant.LOCK_SUFFIX;
+ String token = IdUtil.fastSimpleUUID();
+ boolean locked = Boolean.TRUE.equals(redis.opsForValue()
+ .setIfAbsent(lockKey, token, Duration.ofMillis(bd.getLockTtlMs())));
+
+ if (locked) {
+ try {
+ Lookup r2 = tryGet(dataKey, decoder); // double-check
+ if (r2.hit) {
+ if (r2.realValue) {
+ slidingTouch(dataKey);
+ }
+ return r2.value;
+ }
+ return loadAndCache(dataKey, ttlSeconds, loader);
+ } finally {
+ releaseLock(lockKey, token);
+ }
+ }
+
+ // 未抢到锁:自旋等待重读,超时降级直接回源
+ long waited = 0;
+ while (waited < bd.getWaitTimeoutMs()) {
+ sleep(bd.getWaitIntervalMs());
+ waited += bd.getWaitIntervalMs();
+ Lookup r3 = tryGet(dataKey, decoder);
+ if (r3.hit) {
+ if (r3.realValue) {
+ slidingTouch(dataKey);
+ }
+ return r3.value;
+ }
+ }
+ log.warn("[redis-cache] breakdown wait timeout, fallback to loader, key={}", key);
+ return loader.get();
+ }
+
+ /** 读取三态:未命中 / 命中空值标记 / 命中真实值;反序列化失败当未命中降级。 */
+ private Lookup tryGet(String dataKey, Function decoder) {
+ String raw = redis.opsForValue().get(dataKey);
+ if (raw == null) {
+ return Lookup.miss();
+ }
+ if (codec.isNullSentinel(raw)) {
+ return Lookup.nullHit();
+ }
+ try {
+ return Lookup.hit(decoder.apply(raw));
+ } catch (RuntimeException e) {
+ log.warn("[redis-cache] decode failed, treat as miss key={}", dataKey, e);
+ return Lookup.miss();
+ }
+ }
+
+ /** 回源并写缓存:null → 空值标记(穿透);非 null → 抖动 TTL(雪崩)。 */
+ private T loadAndCache(String dataKey, long ttlSeconds, Supplier loader) {
+ T value = loader.get();
+ if (value == null) {
+ redis.opsForValue().set(dataKey, CacheConstant.NULL_SENTINEL,
+ props.getNullTtlSeconds(), TimeUnit.SECONDS);
+ } else {
+ redis.opsForValue().set(dataKey, codec.encode(value),
+ applyJitter(ttlSeconds), TimeUnit.SECONDS);
+ }
+ return value;
+ }
+
+ private void releaseLock(String lockKey, String token) {
+ try {
+ redis.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
+ } catch (RuntimeException e) {
+ log.warn("[redis-cache] release lock failed lockKey={}", lockKey, e);
+ }
+ }
+
+ private static void sleep(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /** 读取三态载体(对外不暴露)。 */
+ private static final class Lookup {
+ final boolean hit; // 命中(真实值或空值标记)
+ final boolean realValue; // 命中且为真实值
+ final T value;
+
+ private Lookup(boolean hit, boolean realValue, T value) {
+ this.hit = hit;
+ this.realValue = realValue;
+ this.value = value;
+ }
+
+ static Lookup miss() {
+ return new Lookup<>(false, false, null);
+ }
+
+ static Lookup nullHit() {
+ return new Lookup<>(true, false, null);
+ }
+
+ static Lookup hit(T v) {
+ return new Lookup<>(true, true, v);
+ }
+ }
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
+Expected: `Tests run: 18, Failures: 0`(Task 4 的 10 + Task 5 的 8)。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \
+ src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
+git commit -m "feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)"
+```
+
+---
+
+### Task 6: 自动装配 + spring.factories 注册
+
+**Files:**
+- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java`
+- Modify: `src/main/resources/META-INF/spring.factories`
+- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java`
+
+**Interfaces:**
+- Consumes: `RedisCacheProperties`、`CacheKeyBuilder`、`CacheValueCodec`、`RedisCacheEnhanceTemplate`
+- Produces: 三个 `@ConditionalOnMissingBean` Bean(`CacheKeyBuilder`、`CacheValueCodec`、`RedisCacheEnhanceTemplate`),并经 `spring.factories` 自动加载。
+
+- [ ] **Step 1: 写失败测试**
+
+`src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java`
+```java
+package com.njcn.middle.cache.autoconfig;
+
+import com.njcn.middle.cache.support.CacheKeyBuilder;
+import com.njcn.middle.cache.support.CacheValueCodec;
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+class RedisCacheAutoConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(
+ RedisAutoConfiguration.class, RedisCacheAutoConfiguration.class))
+ .withPropertyValues("spring.redis.host=127.0.0.1", "spring.redis.port=6379");
+
+ @Test
+ void registersCacheBeans() {
+ runner.run(ctx -> {
+ assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class);
+ assertThat(ctx).hasSingleBean(CacheKeyBuilder.class);
+ assertThat(ctx).hasSingleBean(CacheValueCodec.class);
+ });
+ }
+
+ @Test
+ void backsOffWhenUserDefinesOwnTemplate() {
+ runner.withUserConfiguration(CustomConfig.class).run(ctx -> {
+ assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class);
+ assertThat(ctx.getBean(RedisCacheEnhanceTemplate.class))
+ .isSameAs(ctx.getBean("myTemplate"));
+ });
+ }
+
+ @Configuration
+ static class CustomConfig {
+ @Bean
+ RedisCacheEnhanceTemplate myTemplate() {
+ return new RedisCacheEnhanceTemplate(
+ new StringRedisTemplate(mock(RedisConnectionFactory.class)),
+ new CacheKeyBuilder("cache:", false, ""),
+ new CacheValueCodec(),
+ new RedisCacheProperties());
+ }
+ }
+}
+```
+
+> ApplicationContextRunner 启动上下文但 Lettuce 懒连接,不会真的连 Redis,故本测试无需真实 Redis。
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml`
+Expected: 编译失败(`RedisCacheAutoConfiguration` 不存在)。
+
+- [ ] **Step 3: 写实现**
+
+`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java`
+```java
+package com.njcn.middle.cache.autoconfig;
+
+import com.njcn.middle.cache.support.CacheKeyBuilder;
+import com.njcn.middle.cache.support.CacheValueCodec;
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * Redis 缓存自动装配。装配在 {@link RedisAutoConfiguration} 之后,确保 {@link StringRedisTemplate} 就绪。
+ * 与 stream 装配互不引用;纯模板、无后台线程。
+ */
+@Configuration
+@ConditionalOnClass(StringRedisTemplate.class)
+@AutoConfigureAfter(RedisAutoConfiguration.class)
+@EnableConfigurationProperties(RedisCacheProperties.class)
+public class RedisCacheAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public CacheKeyBuilder cacheKeyBuilder(RedisCacheProperties props) {
+ return new CacheKeyBuilder(props.getKeyPrefix(), props.isEnvIsolation(), props.getEnv());
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public CacheValueCodec cacheValueCodec() {
+ return new CacheValueCodec();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public RedisCacheEnhanceTemplate redisCacheEnhanceTemplate(StringRedisTemplate redis,
+ CacheKeyBuilder cacheKeyBuilder,
+ CacheValueCodec cacheValueCodec,
+ RedisCacheProperties props) {
+ return new RedisCacheEnhanceTemplate(redis, cacheKeyBuilder, cacheValueCodec, props);
+ }
+}
+```
+
+修改 `src/main/resources/META-INF/spring.factories` 为(追加 cache 装配,逗号续行):
+```
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\
+com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml`
+Expected: `Tests run: 2, Failures: 0`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java \
+ src/main/resources/META-INF/spring.factories \
+ src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java
+git commit -m "feat(cache): 自动装配 + spring.factories 注册"
+```
+
+---
+
+### Task 7: 集成测试 — 收发闭环 RoundTrip(含 Redis 探测脚手架)
+
+**Files:**
+- Create: `src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java`
+- Create: `src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java`
+
+**Interfaces:**
+- Consumes: 自动装配出的 `RedisCacheEnhanceTemplate`(Task 6)
+- Produces: `RedisAvailableCondition`(供 Task 8/9/10 复用的 JUnit5 `ExecutionCondition`)
+
+- [ ] **Step 1: 写测试(集成测试天然是"行为测试",无 Redis 时自动 skip)**
+
+`src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java`
+```java
+package com.njcn.middle.cache.integration;
+
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+import java.net.InetSocketAddress;
+import java.net.Socket;
+
+/** Redis 不可达时禁用整个测试类(skipped,非 failed),保证无 Redis 环境 mvn test 仍全绿。 */
+public class RedisAvailableCondition implements ExecutionCondition {
+ @Override
+ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
+ String host = System.getProperty("spring.redis.host", "127.0.0.1");
+ int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
+ try (Socket s = new Socket()) {
+ s.connect(new InetSocketAddress(host, port), 500);
+ return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
+ } catch (Exception e) {
+ return ConditionEvaluationResult.disabled(
+ "no redis at " + host + ":" + port + ", skip integration test");
+ }
+ }
+}
+```
+
+`src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java`
+```java
+package com.njcn.middle.cache.integration;
+
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 集成测试:真实 Redis 下 set/get/exists/delete 闭环。Redis 不可达自动 skip。
+ *
+ * 连接默认 127.0.0.1:6379;覆盖用 {@code -Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…}。
+ */
+@ExtendWith(RedisAvailableCondition.class)
+@SpringBootTest(classes = CacheRoundTripTest.TestApp.class,
+ properties = {"redis-cache.key-prefix=it-cache:"})
+class CacheRoundTripTest {
+
+ @Autowired
+ private RedisCacheEnhanceTemplate cache;
+
+ @Test
+ void setGetExistsDelete() {
+ String key = "rt:user:1";
+ cache.delete(key);
+
+ cache.set(key, new User(1L, "alice"), 60);
+ User got = cache.get(key, User.class);
+ assertEquals("alice", got.getName());
+ assertTrue(cache.exists(key));
+
+ assertTrue(cache.delete(key));
+ assertNull(cache.get(key, User.class));
+ assertFalse(cache.exists(key));
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class TestApp {
+ }
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class User {
+ private Long id;
+ private String name;
+ }
+}
+```
+
+- [ ] **Step 2: 运行,确认行为(有 Redis 则 PASS;无 Redis 则 skipped)**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheRoundTripTest test -f pom.xml`
+Expected: 有本地 Redis → `Tests run: 1, Failures: 0`;无 Redis → 该类 skipped、构建仍成功。(本地起 Redis:`docker run -d -p 6379:6379 redis:6.2`)
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java \
+ src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java
+git commit -m "test(cache): 集成测试 set/get/exists/delete 闭环 + Redis 探测脚手架"
+```
+
+---
+
+### Task 8: 集成测试 — 穿透(空值缓存,loader 仅调用一次)
+
+**Files:**
+- Create: `src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java`
+
+**Interfaces:**
+- Consumes: `RedisCacheEnhanceTemplate`、`RedisAvailableCondition`(Task 7)
+
+- [ ] **Step 1: 写测试**
+
+`src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java`
+```java
+package com.njcn.middle.cache.integration;
+
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/** 集成测试:回源返回 null 时缓存空值标记,二次查询命中空标记、loader 不再被调用。 */
+@ExtendWith(RedisAvailableCondition.class)
+@SpringBootTest(classes = CachePenetrationTest.TestApp.class,
+ properties = {"redis-cache.key-prefix=it-cache:", "redis-cache.null-ttl-seconds=30"})
+class CachePenetrationTest {
+
+ @Autowired
+ private RedisCacheEnhanceTemplate cache;
+
+ @Test
+ void nullResultCachedAndLoaderCalledOnce() {
+ String key = "pen:missing:1";
+ cache.delete(key);
+
+ AtomicInteger calls = new AtomicInteger();
+ Supplier loader = () -> {
+ calls.incrementAndGet();
+ return null; // 数据不存在
+ };
+
+ assertNull(cache.getOrLoad(key, User.class, 60, loader));
+ assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 命中空值标记
+ assertEquals(1, calls.get());
+
+ cache.delete(key);
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class TestApp {
+ }
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class User {
+ private Long id;
+ private String name;
+ }
+}
+```
+
+- [ ] **Step 2: 运行,确认行为**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CachePenetrationTest test -f pom.xml`
+Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java
+git commit -m "test(cache): 集成测试穿透防护(空值缓存,loader 仅调用一次)"
+```
+
+---
+
+### Task 9: 集成测试 — 显式 expire 续期 + 滑动过期
+
+**Files:**
+- Create: `src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java`
+
+**Interfaces:**
+- Consumes: `RedisCacheEnhanceTemplate`、`CacheKeyBuilder`(用于算实际 key 读 TTL)、`StringRedisTemplate`、`RedisAvailableCondition`
+- 本类配置 `redis-cache.sliding-expire-enabled=true`。
+
+- [ ] **Step 1: 写测试**
+
+`src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java`
+```java
+package com.njcn.middle.cache.integration;
+
+import com.njcn.middle.cache.support.CacheKeyBuilder;
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 集成测试:显式 expire 续期、滑动过期续真实值、空值标记不被续。
+ * default-ttl=3600,故滑动续期会把 TTL 拉到远大于初始 60s。
+ */
+@ExtendWith(RedisAvailableCondition.class)
+@SpringBootTest(classes = CacheExpireSlidingTest.TestApp.class,
+ properties = {
+ "redis-cache.key-prefix=it-cache:",
+ "redis-cache.default-ttl-seconds=3600",
+ "redis-cache.null-ttl-seconds=60",
+ "redis-cache.jitter-ratio=0",
+ "redis-cache.sliding-expire-enabled=true"
+ })
+class CacheExpireSlidingTest {
+
+ @Autowired
+ private RedisCacheEnhanceTemplate cache;
+ @Autowired
+ private StringRedisTemplate redis;
+ @Autowired
+ private CacheKeyBuilder keyBuilder;
+
+ private long ttl(String bizKey) {
+ Long e = redis.getExpire(keyBuilder.build(bizKey), TimeUnit.SECONDS);
+ return e == null ? -1 : e;
+ }
+
+ @Test
+ void explicitExpireExtendsTtl() {
+ String key = "exp:1";
+ cache.set(key, new User(1L, "a"), 60);
+ assertTrue(ttl(key) <= 60 && ttl(key) > 0);
+ assertTrue(cache.expire(key, 600));
+ assertTrue(ttl(key) > 100, "expire 应把 TTL 续到 600 量级");
+ cache.delete(key);
+ }
+
+ @Test
+ void expireOnMissingKeyReturnsFalse() {
+ String key = "exp:missing";
+ cache.delete(key);
+ assertFalse(cache.expire(key, 600));
+ }
+
+ @Test
+ void slidingRenewsRealValueOnGet() {
+ String key = "sld:1";
+ cache.set(key, new User(1L, "a"), 60); // 初始 ~60s
+ assertTrue(ttl(key) <= 60 && ttl(key) > 0);
+ cache.get(key, User.class); // 命中真实值 → 滑动续到 default 3600
+ assertTrue(ttl(key) > 1000, "滑动过期应把 TTL 续到 default 量级");
+ cache.delete(key);
+ }
+
+ @Test
+ void slidingDoesNotRenewNullSentinel() {
+ String key = "sld:missing";
+ cache.delete(key);
+ Supplier loader = () -> null;
+ assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 写空标记 ttl=60
+ long before = ttl(key);
+ assertTrue(before <= 60 && before > 0);
+ assertNull(cache.get(key, User.class)); // 命中空标记,不续
+ assertTrue(ttl(key) <= 60, "空值标记不应被滑动续期");
+ cache.delete(key);
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class TestApp {
+ }
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class User {
+ private Long id;
+ private String name;
+ }
+}
+```
+
+- [ ] **Step 2: 运行,确认行为**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheExpireSlidingTest test -f pom.xml`
+Expected: 有 Redis → `Tests run: 4, Failures: 0`;无 Redis → skipped。
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java
+git commit -m "test(cache): 集成测试显式 expire 续期与滑动过期(真实值续、空标记不续)"
+```
+
+---
+
+### Task 10: 集成测试 — 击穿并发(loader 仅回源一次)
+
+**Files:**
+- Create: `src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java`
+
+**Interfaces:**
+- Consumes: `RedisCacheEnhanceTemplate`、`RedisAvailableCondition`;默认击穿锁配置(enabled=true)。
+
+- [ ] **Step 1: 写测试**
+
+`src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java`
+```java
+package com.njcn.middle.cache.integration;
+
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 集成测试:N 个线程并发 getOrLoad 同一未命中热点 key,慢回源放大竞争窗口,
+ * 互斥锁应保证 loader 仅被调用 1 次,且所有线程拿到同一结果。
+ */
+@ExtendWith(RedisAvailableCondition.class)
+@SpringBootTest(classes = CacheBreakdownConcurrencyTest.TestApp.class,
+ properties = {"redis-cache.key-prefix=it-cache:"})
+class CacheBreakdownConcurrencyTest {
+
+ @Autowired
+ private RedisCacheEnhanceTemplate cache;
+
+ @Test
+ void onlyOneLoaderUnderConcurrency() throws Exception {
+ String key = "bd:hot:1";
+ cache.delete(key);
+
+ AtomicInteger calls = new AtomicInteger();
+ java.util.function.Supplier loader = () -> {
+ calls.incrementAndGet();
+ try {
+ Thread.sleep(300); // 慢回源
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ return new User(1L, "hot");
+ };
+
+ int n = 10;
+ ExecutorService pool = Executors.newFixedThreadPool(n);
+ CountDownLatch ready = new CountDownLatch(n);
+ CountDownLatch go = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+ for (int i = 0; i < n; i++) {
+ futures.add(pool.submit(() -> {
+ ready.countDown();
+ go.await();
+ return cache.getOrLoad(key, User.class, 60, loader);
+ }));
+ }
+ ready.await();
+ go.countDown();
+
+ for (Future f : futures) {
+ assertEquals("hot", f.get(10, TimeUnit.SECONDS).getName());
+ }
+ assertEquals(1, calls.get(), "并发下 loader 应只回源一次");
+
+ pool.shutdownNow();
+ cache.delete(key);
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class TestApp {
+ }
+
+ @Data
+ @AllArgsConstructor
+ @NoArgsConstructor
+ static class User {
+ private Long id;
+ private String name;
+ }
+}
+```
+
+- [ ] **Step 2: 运行,确认行为**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheBreakdownConcurrencyTest test -f pom.xml`
+Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java
+git commit -m "test(cache): 集成测试击穿并发(互斥锁下 loader 仅回源一次)"
+```
+
+---
+
+### Task 11: README 缓存章节 + 全量回归
+
+**Files:**
+- Modify: `README.md`(在文末追加「缓存」章节,**不改动现有 stream 内容**)
+
+**Interfaces:** 无新代码。仅文档 + 全量测试。
+
+- [ ] **Step 1: 在 README 末尾追加缓存章节**
+
+> 先 `git status` 确认 `README.md` 既有的未提交改动是否还在;追加内容到文件**末尾**,不要触碰现有任何行。
+
+在 `README.md` 末尾追加:
+```markdown
+
+---
+
+## 十、Redis 缓存(`RedisCacheEnhanceTemplate`)
+
+除 Stream 收发外,starter 同样提供开箱即用的**编程式缓存**,把 TTL、序列化、穿透/雪崩/击穿三防封装进一个 `getOrLoad`。注入即用,无需额外注解。
+
+### 发送/读取
+
+```java
+import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserCache {
+
+ private final RedisCacheEnhanceTemplate cache;
+
+ public UserCache(RedisCacheEnhanceTemplate cache) {
+ this.cache = cache;
+ }
+
+ public User getUser(Long id) {
+ // 命中即返回;未命中则回源 loadFromDb 并写缓存,自动防穿透/雪崩/击穿
+ return cache.getOrLoad("user:" + id, User.class, 3600, () -> loadFromDb(id));
+ }
+
+ public void evict(Long id) {
+ cache.delete("user:" + id);
+ }
+}
+```
+
+核心方法:
+
+| 方法 | 说明 |
+|---|---|
+| `set(key, value[, ttlSeconds])` | 写缓存,TTL 叠加随机抖动(防雪崩);不传 TTL 用 `default-ttl-seconds` |
+| `get(key, Class)` / `get(key, TypeReference)` | 读缓存,支持泛型;未命中或命中空值标记返回 `null`;脏数据抛 `IllegalStateException` |
+| `delete(key)` / `exists(key)` | 删除 / 判存 |
+| `expire(key, ttlSeconds)` | 显式续期(走抖动);key 不存在返回 `false` |
+| `getOrLoad(key, type[, ttlSeconds], loader)` | 读取或回源;内置穿透(空值标记)+ 雪崩(TTL 抖动)+ 击穿(互斥锁)三防 |
+
+### 配置项(前缀 `redis-cache`,均可选)
+
+```yaml
+redis-cache:
+ key-prefix: "cache:" # 统一 key 前缀
+ env-isolation: false # 按环境隔离 key(开启且 env 非空 → cache:env:key)
+ env: ""
+ default-ttl-seconds: 3600 # 默认 TTL(秒)
+ null-ttl-seconds: 60 # 空值标记 TTL(秒,穿透防护)
+ jitter-ratio: 0.1 # TTL 随机抖动比例(雪崩防护;0 关闭)
+ sliding-expire-enabled: false # 读命中真实值自动续 default-ttl(滑动过期,空值标记不续)
+ breakdown: # 击穿:互斥锁
+ enabled: true # 关闭则退化为无锁直接回源
+ lock-ttl-ms: 10000 # 锁持有 TTL
+ wait-timeout-ms: 3000 # 未抢到锁的最长等待,超时降级直接回源
+ wait-interval-ms: 50 # 未抢到锁的自旋重读间隔
+```
+
+> 缓存模块与 Stream 完全独立:只用缓存不注册任何 handler 也能工作;反之亦然。
+```
+
+- [ ] **Step 2: 全量回归(确认整库全绿,且未破坏 stream 任何测试)**
+
+Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml`
+Expected: `BUILD SUCCESS`;所有单元测试 PASS;集成测试在有 Redis 时 PASS、无 Redis 时 skipped;**stream 既有测试不受影响**。
+
+- [ ] **Step 3: 提交(只提交 README)**
+
+```bash
+git add README.md
+git commit -m "docs(cache): README 增补缓存章节(用法与配置)"
+```
+
+> ⚠️ 仍只 `git add README.md`;若 `git status` 显示 README 还有本功能之外的旧改动,先与维护者确认再决定是否分离提交,**不要**用 `git add -A`。
+
+---
+
+## Self-Review(本计划对照 spec 的自检结果)
+
+- **spec §一/§六 API**:`set/get/delete/exists/expire/getOrLoad` → Task 4(读写)+ Task 5(getOrLoad)。✅
+- **spec §五 序列化/空值标记**:`CacheValueCodec` + `CacheConstant.NULL_SENTINEL` → Task 1/2。✅
+- **spec §四 Key 设计**:`CacheKeyBuilder.build/buildLock` → Task 1。✅
+- **spec §三 配置项**:`RedisCacheProperties` 全字段 + `sliding-expire-enabled` → Task 3。✅
+- **spec §七 三防算法**:空值穿透 + TTL 抖动 + 互斥锁 + double-check + 超时降级 → Task 5。✅
+- **spec 滑动过期**:`slidingTouch`(只续真实值)→ Task 4(get)+ Task 5(getOrLoad);空值不续 → 单测 + 集成(Task 9)。✅
+- **spec §八 自动装配**:`RedisCacheAutoConfiguration` + spring.factories → Task 6。✅
+- **spec §九 错误处理**:get 抛 `IllegalStateException`(Task 4)、getOrLoad 内部解码失败降级(Task 5)、续期失败忽略(Task 4/5)。✅
+- **spec §十 测试策略**:单测(Task 1-6)+ 集成 RoundTrip/穿透/expire-滑动/击穿(Task 7-10),复用不可达 skip。✅
+- **类型一致性**:`build/buildLock`、`encode/decode/isNullSentinel`、`applyJitter`、`getOrLoad` 重载签名在各任务间一致;`Lookup` 三态、`UNLOCK_SCRIPT` 仅在 Task 5 内部。✅
+- **无新依赖 / 不改 stream**:仅改 `spring.factories`、`README.md`。✅
+```