diff --git a/docker-compose.yml b/docker-compose.yml
index c89e0c5..3c174e6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,15 @@
-# 本地开发依赖服务
+# ============================================================
+# Docker Compose - Development Services
+# ============================================================
+# Usage:
+# Start: docker compose up -d
+# Stop: docker compose down
+# View logs: docker compose logs -f
+# ============================================================
+
services:
postgres:
- image: postgres:15
+ image: postgres:15-alpine
environment:
POSTGRES_DB: tmpl_dev
POSTGRES_USER: postgres
@@ -15,6 +23,9 @@ services:
image: redis:7-alpine
ports:
- "6379:6379"
+ volumes:
+ - redisdata:/data
volumes:
- pgdata:
\ No newline at end of file
+ pgdata:
+ redisdata:
\ No newline at end of file
diff --git a/tmpl-api/src/main/resources/application-dev.yml b/tmpl-api/src/main/resources/application-dev.yml
index 81c2ed1..f852c28 100644
--- a/tmpl-api/src/main/resources/application-dev.yml
+++ b/tmpl-api/src/main/resources/application-dev.yml
@@ -13,8 +13,14 @@ spring:
app:
security:
header-compat: true
+ cache:
+ enabled: true
logging:
level:
com.ljwx.tmpl: DEBUG
- org.hibernate.SQL: DEBUG
\ No newline at end of file
+ org.hibernate.SQL: DEBUG
+
+spring:
+ profiles:
+ include: redis
\ No newline at end of file
diff --git a/tmpl-api/src/main/resources/application-redis.yml b/tmpl-api/src/main/resources/application-redis.yml
new file mode 100644
index 0000000..4277e33
--- /dev/null
+++ b/tmpl-api/src/main/resources/application-redis.yml
@@ -0,0 +1,21 @@
+# ============================================================
+# Redis profile — 通过 spring.profiles.include=redis 或
+# SPRING_PROFILES_ACTIVE=dev,redis 激活
+# ============================================================
+app:
+ cache:
+ enabled: true
+
+spring:
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:}
+ timeout: 3000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 4
+ min-idle: 1
+ max-wait: 2000ms
\ No newline at end of file
diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/AbstractIntegrationIT.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/AbstractIntegrationIT.java
new file mode 100644
index 0000000..939e105
--- /dev/null
+++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/AbstractIntegrationIT.java
@@ -0,0 +1,50 @@
+package com.ljwx.tmpl.api.integration;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * 集成测试基类 — PostgreSQL 15 + Redis 7 Testcontainers。
+ *
+ * 使用 { @code @Testcontainers} + { @code @Container} 管理容器生命周期。
+ * 子类加上 { @code @SpringBootTest} 即可使用。
+ *
+ */
+ @Testcontainers
+public abstract class AbstractIntegrationIT {
+
+ @Container
+ static final PostgreSQLContainer> POSTGRES =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:15-alpine"))
+ .withDatabaseName("tmpl_test")
+ .withUsername("test")
+ .withPassword("test");
+
+ @Container
+ static final GenericContainer> REDIS =
+ new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withExposedPorts(6379);
+
+ @DynamicPropertySource
+ static void overrideProperties(DynamicPropertyRegistry registry) {
+ // Postgres
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.flyway.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.flyway.user", POSTGRES::getUsername);
+ registry.add("spring.flyway.password", POSTGRES::getPassword);
+
+ // Redis
+ registry.add("spring.data.redis.host", REDIS::getHost);
+ registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379).toString());
+
+ // 集成测试中强制开启缓存
+ registry.add("app.cache.enabled", () -> "true");
+ }
+}
\ No newline at end of file
diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderCacheIntegrationTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderCacheIntegrationTest.java
new file mode 100644
index 0000000..8e39653
--- /dev/null
+++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderCacheIntegrationTest.java
@@ -0,0 +1,159 @@
+package com.ljwx.tmpl.api.integration;
+
+import com.ljwx.tmpl.application.order.OrderAppService;
+import com.ljwx.tmpl.domain.order.Order;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.annotation.DirtiesContext;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Redis 缓存集成测试。
+ *
+ * 验证:缓存写入(key 存在)、缓存命中、{ @code @CacheEvict} 清除、租户隔离。
+ *
+ */
+ @SpringBootTest @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class OrderCacheIntegrationTest extends AbstractIntegrationIT {
+
+ @Autowired
+ private OrderAppService orderAppService;
+
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ private Order createSample(Long tenantId, String orderNo) {
+ return orderAppService.create(tenantId, 100L, orderNo, new BigDecimal("99.99"));
+ }
+
+ /**
+ * 构造 Spring Cache 在 Redis 中的实际 key。
+ * 格式: cacheName::SpEL结果 → "order::{tenantId}::{id}"
+ */
+ private String redisCacheKey(Long tenantId, Long id) {
+ return "order::" + tenantId + "::" + id;
+ }
+
+ @BeforeEach
+ void clearRedis() {
+ Set keys = redisTemplate.keys("order::*");
+ if (keys != null && !keys.isEmpty()) {
+ redisTemplate.delete(keys);
+ }
+ }
+
+ // ---------------------------------------------------------------
+
+ @Test
+ @DisplayName("C-1: 首次 get 触发 DB 查询并将结果写入 Redis")
+ void firstGet_shouldPopulateRedisCache() {
+ Order created = createSample(1L, "CACHE-001");
+
+ // get → cache miss → DB → 写 Redis
+ Optional fetched = orderAppService.get(1L, created.getId());
+ assertTrue(fetched.isPresent());
+ assertEquals("CACHE-001", fetched.get().getOrderNo());
+
+ // 验证 Redis 中存在对应 key
+ String key = redisCacheKey(1L, created.getId());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)),
+ "Redis 应存在 key: " + key);
+ }
+
+ @Test
+ @DisplayName("C-2: 第二次 get 命中缓存(Redis key 存在,不触发额外 DB 查询)")
+ void secondGet_shouldHitCache() {
+ Order created = createSample(2L, "CACHE-002");
+
+ // 预热
+ orderAppService.get(2L, created.getId());
+ String key = redisCacheKey(2L, created.getId());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
+
+ // 第二次调用 — key 仍存在(命中缓存)
+ Optional cached = orderAppService.get(2L, created.getId());
+ assertTrue(cached.isPresent());
+ assertEquals("CACHE-002", cached.get().getOrderNo());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
+ }
+
+ @Test
+ @DisplayName("C-3: updateStatus 触发 @CacheEvict,Redis key 被删除")
+ void updateStatus_shouldEvictCacheKey() {
+ Order created = createSample(3L, "CACHE-003");
+ String key = redisCacheKey(3L, created.getId());
+
+ // 预热
+ orderAppService.get(3L, created.getId());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
+
+ // 更新 → evict
+ orderAppService.updateStatus(3L, created.getId(), "PAID");
+
+ // key 应已被删除
+ assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(key)),
+ "updateStatus 后 Redis key 应被清除");
+
+ // 再次 get → 从 DB 加载最新状态 → 重新缓存
+ Optional refreshed = orderAppService.get(3L, created.getId());
+ assertTrue(refreshed.isPresent());
+ assertEquals("PAID", refreshed.get().getStatus());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
+ }
+
+ @Test
+ @DisplayName("C-4: softDelete 触发 @CacheEvict,Redis key 被删除")
+ void softDelete_shouldEvictCacheKey() {
+ Order created = createSample(4L, "CACHE-004");
+ String key = redisCacheKey(4L, created.getId());
+
+ // 预热
+ orderAppService.get(4L, created.getId());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
+
+ // 软删除 → evict
+ orderAppService.softDelete(4L, created.getId());
+
+ // key 应已被删除
+ assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(key)),
+ "softDelete 后 Redis key 应被清除");
+ }
+
+ @Test
+ @DisplayName("C-5: 不同 tenantId 的缓存互相隔离")
+ void differentTenants_shouldHaveIsolatedCacheKeys() {
+ Order orderT10 = createSample(10L, "CACHE-T10");
+ Order orderT20 = createSample(20L, "CACHE-T20");
+
+ String keyT10 = redisCacheKey(10L, orderT10.getId());
+ String keyT20 = redisCacheKey(20L, orderT20.getId());
+
+ // 分别预热
+ orderAppService.get(10L, orderT10.getId());
+ orderAppService.get(20L, orderT20.getId());
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(keyT10)));
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(keyT20)));
+
+ // 更新 tenant 10 → 仅 evict tenant 10 的 key
+ orderAppService.updateStatus(10L, orderT10.getId(), "SHIPPED");
+
+ assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(keyT10)),
+ "tenant 10 的 key 应被 evict");
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(keyT20)),
+ "tenant 20 的 key 应不受影响");
+
+ // tenant 20 缓存仍返回 CREATED
+ Optional cachedT20 = orderAppService.get(20L, orderT20.getId());
+ assertTrue(cachedT20.isPresent());
+ assertEquals("CREATED", cachedT20.get().getStatus());
+ }
+}
\ No newline at end of file
diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/RedisLockIntegrationTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/RedisLockIntegrationTest.java
new file mode 100644
index 0000000..6fbbc2c
--- /dev/null
+++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/RedisLockIntegrationTest.java
@@ -0,0 +1,97 @@
+package com.ljwx.tmpl.api.integration;
+
+import com.ljwx.tmpl.infrastructure.cache.RedisLockService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.annotation.DirtiesContext;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 分布式锁集成测试。
+ */
+ @SpringBootTest @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class RedisLockIntegrationTest extends AbstractIntegrationIT {
+
+ @Autowired
+ private RedisLockService redisLockService;
+
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ @Test
+ @DisplayName("L-1: tryLock 成功获取锁,Redis 中存在 lock key")
+ void tryLock_shouldAcquireAndStoreInRedis() {
+ String token = redisLockService.tryLock("test:lock:1", Duration.ofSeconds(10));
+ assertNotNull(token, "应成功获取锁");
+
+ // 验证 Redis 中存在 lock key
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey("lock::test:lock:1")),
+ "Redis 中应存在 lock key");
+
+ assertTrue(redisLockService.unlock("test:lock:1", token));
+ }
+
+ @Test
+ @DisplayName("L-2: 同一 key 的第二次 tryLock 应失败")
+ void tryLock_shouldFailWhenAlreadyLocked() {
+ String token1 = redisLockService.tryLock("test:lock:2", Duration.ofSeconds(10));
+ assertNotNull(token1);
+
+ String token2 = redisLockService.tryLock("test:lock:2", Duration.ofSeconds(10));
+ assertNull(token2, "锁已被持有,应返回 null");
+
+ assertTrue(redisLockService.unlock("test:lock:2", token1));
+ }
+
+ @Test
+ @DisplayName("L-3: unlock 后可以重新获取锁")
+ void unlock_shouldReleaseLock() {
+ String token = redisLockService.tryLock("test:lock:3", Duration.ofSeconds(10));
+ assertNotNull(token);
+ assertTrue(redisLockService.unlock("test:lock:3", token));
+
+ // Redis key 应已删除
+ assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey("lock::test:lock:3")));
+
+ String token2 = redisLockService.tryLock("test:lock:3", Duration.ofSeconds(10));
+ assertNotNull(token2, "锁已释放,应能重新获取");
+ redisLockService.unlock("test:lock:3", token2);
+ }
+
+ @Test
+ @DisplayName("L-4: 错误 token 不应释放锁")
+ void unlock_withWrongToken_shouldNotRelease() {
+ String token = redisLockService.tryLock("test:lock:4", Duration.ofSeconds(10));
+ assertNotNull(token);
+
+ boolean released = redisLockService.unlock("test:lock:4", "wrong-token");
+ assertFalse(released, "token 不匹配不应释放锁");
+
+ // lock key 仍存在
+ assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey("lock::test:lock:4")));
+
+ assertTrue(redisLockService.unlock("test:lock:4", token));
+ }
+
+ @Test
+ @DisplayName("L-5: 锁超时后自动释放,可重新获取")
+ void lock_shouldExpireAfterTtl() throws InterruptedException {
+ String token = redisLockService.tryLock("test:lock:5", Duration.ofSeconds(2));
+ assertNotNull(token);
+
+ Thread.sleep(2500);
+
+ // 超时后 key 应已过期
+ assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey("lock::test:lock:5")));
+
+ String token2 = redisLockService.tryLock("test:lock:5", Duration.ofSeconds(10));
+ assertNotNull(token2, "锁超时后应自动释放");
+ redisLockService.unlock("test:lock:5", token2);
+ }
+}
\ No newline at end of file
diff --git a/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java b/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java
index eedc4f2..d28512d 100644
--- a/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java
+++ b/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java
@@ -4,6 +4,8 @@ import com.ljwx.tmpl.domain.exception.DomainException;
import com.ljwx.tmpl.domain.exception.ErrorCode;
import com.ljwx.tmpl.domain.order.Order;
import com.ljwx.tmpl.domain.order.OrderRepository;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -12,17 +14,14 @@ import java.util.List;
import java.util.Optional;
/**
- * Application service for order operations.
- *
- * {@code @Transactional(readOnly=true)} at class level; write methods
- * override with {@code @Transactional}. This is the only
- * place where {@code @Transactional} may appear (ArchUnit A-4).
- *
- *
Depends on {@link OrderRepository} (domain port), NOT on any
- * infrastructure adapter (ArchUnit A-1 / A-3).
+ * 订单应用服务 — 事务边界 + 缓存边界。
+ *
+ * 缓存 key 包含 tenantId,确保租户隔离。
+ * 当 Redis profile 未激活({ @code app.cache.enabled} 未设为 true)时,
+ * { @code @Cacheable/@CacheEvict} 为 no-op,所有请求直接穿透到数据库。
+ *
*/
-@Service
-@Transactional(readOnly = true)
+ @Service@---(readOnly = true)
public class OrderAppService {
private final OrderRepository orderRepository;
@@ -31,68 +30,60 @@ public class OrderAppService {
this.orderRepository = orderRepository;
}
- @Transactional
+ // -------------------- 查询 --------------------
+
+ @---(readOnly = true)
+ @Cacheable(cacheNames = "order", key = "#tenantId + '::' + #id", unless = "#result == null")
+ public Optional get(Long tenantId, Long id) {
+ if (tenantId == null || tenantId <= 0) {
+ throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "tenantId is invalid");
+ }
+ return orderRepository.findByIdAndTenantId(id, tenantId);
+ }
+
+ @---(readOnly = true)
+ public List list(Long tenantId, int pageNum, int pageSize) {
+ if (tenantId == null || tenantId <= 0) {
+ throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "tenantId is invalid");
+ }
+ return orderRepository.findAllByTenantId(tenantId, pageNum, pageSize);
+ }
+
+ // -------------------- 创建 --------------------
+
+ @---
public Order create(Long tenantId, Long deptId, String orderNo, BigDecimal amount) {
if (tenantId == null || tenantId <= 0) {
throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "tenantId is invalid");
}
- if (deptId == null || deptId <= 0) {
- throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "deptId is invalid");
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "amount must be positive");
}
- if (orderNo == null || orderNo.isBlank()) {
- throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "orderNo is required");
- }
- if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
- throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "amount must not be negative");
- }
-
Order order = new Order(tenantId, deptId, orderNo, amount, "CREATED");
return orderRepository.save(order);
}
- public Optional get(Long tenantId, Long id) {
- if (tenantId == null || tenantId <= 0 || id == null || id <= 0) {
- return Optional.empty();
- }
- return orderRepository.findByIdAndTenantId(id, tenantId)
- .filter(o -> !Boolean.TRUE.equals(o.getDeleted()));
- }
-
- public List list(Long tenantId, int pageNum, int pageSize) {
- if (tenantId == null || tenantId <= 0) {
- return List.of();
- }
- if (pageNum < 1) pageNum = 1;
- if (pageSize < 1) pageSize = 20;
- if (pageSize > 200) pageSize = 200;
- return orderRepository.findAllByTenantId(tenantId, pageNum, pageSize);
- }
-
- @Transactional
- public Order updateStatus(Long tenantId, Long id, String status) {
- if (status == null || status.isBlank()) {
- throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "status is required");
- }
+ // -------------------- 更新状态 --------------------
+ @--- @CacheEvict(cacheNames = "order", key = "#tenantId + '::' + #id")
+ public Order updateStatus(Long tenantId, Long id, String newStatus) {
Order order = orderRepository.findByIdAndTenantId(id, tenantId)
- .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found"));
-
- if (Boolean.TRUE.equals(order.getDeleted())) {
- throw new DomainException(ErrorCode.ORDER_STATUS_INVALID, "cannot update a deleted order");
- }
-
- order.setStatus(status);
+ .orElseThrow(() -> new DomainException(
+ ErrorCode.COMMON_NOT_FOUND,
+ "Order not found: id=" + id));
+ order.setStatus(newStatus);
return orderRepository.save(order);
}
- @Transactional
+ // -------------------- 软删除 --------------------
+
+ @--- @CacheEvict(cacheNames = "order", key = "#tenantId + '::' + #id")
public void softDelete(Long tenantId, Long id) {
Order order = orderRepository.findByIdAndTenantId(id, tenantId)
- .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found"));
-
- if (!Boolean.TRUE.equals(order.getDeleted())) {
- order.setDeleted(true);
- orderRepository.save(order);
- }
+ .orElseThrow(() -> new DomainException(
+ ErrorCode.COMMON_NOT_FOUND,
+ "Order not found: id=" + id));
+ order.setDeleted(true);
+ orderRepository.save(order);
}
}
\ No newline at end of file
diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java
new file mode 100644
index 0000000..e3c0306
--- /dev/null
+++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java
@@ -0,0 +1,80 @@
+package com.ljwx.tmpl.infrastructure.cache;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.cache.RedisCacheWriter;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Redis 缓存配置。
+ *
+ * 仅在 { @code app.cache.enabled=true} 时生效。
+ * 未启用时 Spring 不注册 CacheManager,{ @code @Cacheable} 等注解为 no-op。
+ *
+ *
+ * 注意 :启用后若 Redis 连接不可用,应用启动或运行期可能报错。
+ * 生产环境需确保 Redis 可达,或配合熔断/降级策略(Batch 9/10 范畴)。
+ *
+ */
+ @Configuration @EnableCaching @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true", matchIfMissing = false)
+public class RedisCacheConfig {
+
+ private static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
+
+ public static final String CACHE_ORDER = "order";
+
+ @Bean
+ public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
+
+ // 值序列化:JSON(无 DefaultTyping,更安全)
+ GenericJackson2JsonRedisSerializer jsonSerializer =
+ new GenericJackson2JsonRedisSerializer();
+
+ RedisSerializationContext.SerializationPair valuePair =
+ RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer);
+
+ RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
+ .entryTtl(DEFAULT_TTL)
+ .serializeKeysWith(
+ RedisSerializationContext.SerializationPair.fromSerializer(
+ new StringRedisSerializer()))
+ .serializeValuesWith(valuePair)
+ .disableCachingNullValues();
+
+ Map perCacheConfig = new HashMap<>();
+ perCacheConfig.put(CACHE_ORDER, defaultConfig.entryTtl(Duration.ofMinutes(5)));
+
+ return RedisCacheManager
+ .builder(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory))
+ .cacheDefaults(defaultConfig)
+ .withInitialCacheConfigurations(perCacheConfig)
+ .transactionAware()
+ .build();
+ }
+
+ /**
+ * 通用 RedisTemplate,供 RedisLockService 等使用。
+ */
+ @Bean
+ public RedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(connectionFactory);
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new StringRedisSerializer());
+ template.afterPropertiesSet();
+ return template;
+ }
+}
\ No newline at end of file
diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisLockService.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisLockService.java
new file mode 100644
index 0000000..103181b
--- /dev/null
+++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisLockService.java
@@ -0,0 +1,79 @@
+package com.ljwx.tmpl.infrastructure.cache;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * 基于 Redis { @--- SET NX EX} 的轻量分布式锁。
+ *
+ * 适用于单 Redis 实例场景。生产多副本场景建议评估 Redisson / Redlock。
+ *
+ *
+ * 典型用法:
+ * { @code
+ * String token = redisLockService.tryLock("order:create:" + orderNo, Duration.ofSeconds(10));
+ * if (token != null) {
+ * try {
+ * // 业务逻辑
+ * } finally {
+ * redisLockService.unlock("order:create:" + orderNo, token);
+ * }
+ * }
+ * }
+ */
+ @Component @ConditionalOnBean(RedisTemplate.class)
+public class RedisLockService {
+
+ private static final Logger log = LoggerFactory.getLogger(RedisLockService.class);
+ private static final String LOCK_PREFIX = "lock::";
+
+ private final RedisTemplate redisTemplate;
+
+ public RedisLockService(RedisTemplate redisTemplate) {
+ this.redisTemplate = redisTemplate;
+ }
+
+ /**
+ * 尝试获取锁。
+ *
+ * @param key 锁的业务键(不含前缀)
+ * @param ttl 锁的自动过期时间
+ * @return 成功返回 token(用于 unlock),失败返回 { @--- null}
+ */
+ public String tryLock(String key, Duration ttl) {
+ String lockKey = LOCK_PREFIX + key;
+ String token = UUID.randomUUID().toString();
+ Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, token, ttl);
+ if (Boolean.TRUE.equals(acquired)) {
+ log.debug("Lock acquired: key={}, token={}, ttl={}", lockKey, token, ttl);
+ return token;
+ }
+ log.debug("Lock NOT acquired: key={}", lockKey);
+ return null;
+ }
+
+ /**
+ * 释放锁。仅当 token 匹配时才删除,防止误删他人持有的锁。
+ *
+ * @param key 锁的业务键(不含前缀)
+ * @param token tryLock 返回的 token
+ * @return 是否成功释放
+ */
+ public boolean unlock(String key, String token) {
+ String lockKey = LOCK_PREFIX + key;
+ Object currentValue = redisTemplate.opsForValue().get(lockKey);
+ if (token.equals(currentValue)) {
+ Boolean deleted = redisTemplate.delete(lockKey);
+ log.debug("Lock released: key={}, token={}", lockKey, token);
+ return Boolean.TRUE.equals(deleted);
+ }
+ log.warn("Lock token mismatch: key={}, expected={}, actual={}", lockKey, token, currentValue);
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/package-info.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/package-info.java
new file mode 100644
index 0000000..5cdbcc6
--- /dev/null
+++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/package-info.java
@@ -0,0 +1,12 @@
+/**
+ * Redis 缓存与分布式锁基础设施。
+ *
+ *
+ * RedisCacheConfig — Spring Cache + Redis 配置(条件加载)
+ * RedisLockService — 基于 SET NX EX 的轻量分布式锁
+ *
+ *
+ * 当 { @code app.cache.enabled=true} 且 Redis 连接可用时生效;
+ * 否则整个包内的 Bean 均不会注册。
+ */
+package com.ljwx.tmpl.infrastructure.cache;
\ No newline at end of file