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