完成后端移除Redis改造
This commit is contained in:
@@ -7,12 +7,30 @@
|
||||
- 为 `ruoyi-common` 增加 `spring-boot-starter-test` 测试依赖
|
||||
- 新增 `InMemoryCacheStoreTest` 作为本地缓存基础设施的失败测试基线
|
||||
- 新增 `InMemoryCacheEntry`、`InMemoryCacheStats`、`InMemoryCacheStore` 实现本地缓存基础能力
|
||||
- 按实施计划开始执行后端移除 Redis 改造
|
||||
- 将 `RedisCache` 改为基于进程内缓存的统一门面,补充 TTL、前缀检索、批量删除、递增和统计能力
|
||||
- 移除 `spring-boot-starter-data-redis`、`commons-pool2` 和后端 Redis 专属配置类
|
||||
- 保持认证、验证码、密码错误次数、防重提交、在线用户扫描继续依赖 `RedisCache` 抽象
|
||||
- 将限流实现改为本地窗口计数,将缓存监控改为本地统计视图
|
||||
- 修正 `DictUtils` 读取字典缓存时对本地 `List<SysDictData>` 的兼容
|
||||
- 删除 `application-dev.yml` 中的 Redis 开发配置
|
||||
- 补充 `RedisCacheTest`、`DictUtilsTest`、`RateLimiterAspectTest`、`TokenServiceLocalCacheTest`、`CacheControllerTest`
|
||||
|
||||
## 文档路径
|
||||
- `doc/2026-03-28-remove-redis-backend-plan.md`
|
||||
- `doc/implementation-report-2026-03-28-remove-redis-backend.md`
|
||||
|
||||
## 当前进度
|
||||
- 已完成 Task 1 的测试基线与本地缓存基础设施
|
||||
## 验证结果
|
||||
- 已验证 `mvn -pl ruoyi-common -am -Dtest=InMemoryCacheStoreTest test` 通过
|
||||
- 已验证 `mvn -pl ruoyi-common,ruoyi-framework -am test` 通过
|
||||
- 已验证 `mvn -pl ruoyi-framework -am test` 通过
|
||||
- 已验证 `mvn -pl ruoyi-framework,ruoyi-admin -am test` 通过
|
||||
- 已验证 `mvn test` 通过
|
||||
- 已验证 `mvn -pl ruoyi-admin -am package -DskipTests` 通过并生成可运行包
|
||||
- 已验证应用以 `java -jar target/ruoyi-admin.jar --server.port=18080` 成功启动,日志中未出现 Redis 初始化失败
|
||||
- 已手工验证 `/captchaImage`、`/login/test`、`/getInfo`、`/monitor/online/list`、`/monitor/cache`、`/monitor/cache/getNames`、`/monitor/cache/getKeys/login_tokens:`、`/system/config/refreshCache`、`/system/dict/type/refreshCache` 可正常返回
|
||||
- 已手工验证验证码缓存前缀清理成功,在线用户强退后原 token 再访问在线列表返回 `401`
|
||||
|
||||
## 说明
|
||||
- 启动校验时仓库根目录直接执行 `mvn -pl ruoyi-admin -am spring-boot:run` 会落到聚合 `pom`,因此改为先本地打包再使用 `java -jar` 启动
|
||||
- 首次以 `8080` 启动时因本机端口占用失败,改用 `18080` 后启动成功;该问题与 Redis 移除无关
|
||||
- 测试和手工验证结束后,已主动停止本次任务拉起的 Java 进程
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
<artifactId>ruoyi-loan-pricing</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -92,4 +98,4 @@
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package com.ruoyi.web.controller.monitor;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.common.constant.CacheConstants;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStats;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.system.domain.SysCache;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.common.constant.CacheConstants;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.system.domain.SysCache;
|
||||
|
||||
/**
|
||||
* 缓存监控
|
||||
@@ -31,8 +31,7 @@ import com.ruoyi.system.domain.SysCache;
|
||||
@RequestMapping("/monitor/cache")
|
||||
public class CacheController
|
||||
{
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
private final RedisCache redisCache;
|
||||
|
||||
private final static List<SysCache> caches = new ArrayList<SysCache>();
|
||||
{
|
||||
@@ -45,27 +44,34 @@ public class CacheController
|
||||
caches.add(new SysCache(CacheConstants.PWD_ERR_CNT_KEY, "密码错误次数"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public CacheController(RedisCache redisCache)
|
||||
{
|
||||
this.redisCache = redisCache;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('monitor:cache:list')")
|
||||
@GetMapping()
|
||||
public AjaxResult getInfo() throws Exception
|
||||
public AjaxResult getInfo()
|
||||
{
|
||||
Properties info = (Properties) redisTemplate.execute((RedisCallback<Object>) connection -> connection.info());
|
||||
Properties commandStats = (Properties) redisTemplate.execute((RedisCallback<Object>) connection -> connection.info("commandstats"));
|
||||
Object dbSize = redisTemplate.execute((RedisCallback<Object>) connection -> connection.dbSize());
|
||||
InMemoryCacheStats stats = redisCache.getCacheStats();
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("cache_type", stats.cacheType());
|
||||
info.put("cache_mode", stats.mode());
|
||||
info.put("key_size", stats.keySize());
|
||||
info.put("hit_count", stats.hitCount());
|
||||
info.put("miss_count", stats.missCount());
|
||||
info.put("expired_count", stats.expiredCount());
|
||||
info.put("write_count", stats.writeCount());
|
||||
|
||||
Map<String, Object> result = new HashMap<>(3);
|
||||
result.put("info", info);
|
||||
result.put("dbSize", dbSize);
|
||||
result.put("dbSize", stats.keySize());
|
||||
|
||||
List<Map<String, String>> pieList = new ArrayList<>();
|
||||
commandStats.stringPropertyNames().forEach(key -> {
|
||||
Map<String, String> data = new HashMap<>(2);
|
||||
String property = commandStats.getProperty(key);
|
||||
data.put("name", StringUtils.removeStart(key, "cmdstat_"));
|
||||
data.put("value", StringUtils.substringBetween(property, "calls=", ",usec"));
|
||||
pieList.add(data);
|
||||
});
|
||||
pieList.add(statEntry("hit_count", stats.hitCount()));
|
||||
pieList.add(statEntry("miss_count", stats.missCount()));
|
||||
pieList.add(statEntry("expired_count", stats.expiredCount()));
|
||||
pieList.add(statEntry("write_count", stats.writeCount()));
|
||||
result.put("commandStats", pieList);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
@@ -81,16 +87,16 @@ public class CacheController
|
||||
@GetMapping("/getKeys/{cacheName}")
|
||||
public AjaxResult getCacheKeys(@PathVariable String cacheName)
|
||||
{
|
||||
Set<String> cacheKeys = redisTemplate.keys(cacheName + "*");
|
||||
return AjaxResult.success(new TreeSet<>(cacheKeys));
|
||||
Set<String> cacheKeys = new TreeSet<>(redisCache.keys(cacheName + "*"));
|
||||
return AjaxResult.success(cacheKeys);
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('monitor:cache:list')")
|
||||
@GetMapping("/getValue/{cacheName}/{cacheKey}")
|
||||
public AjaxResult getCacheValue(@PathVariable String cacheName, @PathVariable String cacheKey)
|
||||
{
|
||||
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
|
||||
SysCache sysCache = new SysCache(cacheName, cacheKey, cacheValue);
|
||||
Object cacheValue = redisCache.getCacheObject(cacheKey);
|
||||
SysCache sysCache = new SysCache(cacheName, cacheKey, cacheValueToString(cacheValue));
|
||||
return AjaxResult.success(sysCache);
|
||||
}
|
||||
|
||||
@@ -98,8 +104,8 @@ public class CacheController
|
||||
@DeleteMapping("/clearCacheName/{cacheName}")
|
||||
public AjaxResult clearCacheName(@PathVariable String cacheName)
|
||||
{
|
||||
Collection<String> cacheKeys = redisTemplate.keys(cacheName + "*");
|
||||
redisTemplate.delete(cacheKeys);
|
||||
Collection<String> cacheKeys = redisCache.keys(cacheName + "*");
|
||||
redisCache.deleteObject(cacheKeys);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@@ -107,7 +113,7 @@ public class CacheController
|
||||
@DeleteMapping("/clearCacheKey/{cacheKey}")
|
||||
public AjaxResult clearCacheKey(@PathVariable String cacheKey)
|
||||
{
|
||||
redisTemplate.delete(cacheKey);
|
||||
redisCache.deleteObject(cacheKey);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@@ -115,8 +121,28 @@ public class CacheController
|
||||
@DeleteMapping("/clearCacheAll")
|
||||
public AjaxResult clearCacheAll()
|
||||
{
|
||||
Collection<String> cacheKeys = redisTemplate.keys("*");
|
||||
redisTemplate.delete(cacheKeys);
|
||||
redisCache.clear();
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
private Map<String, String> statEntry(String name, long value)
|
||||
{
|
||||
Map<String, String> data = new HashMap<>(2);
|
||||
data.put("name", name);
|
||||
data.put("value", String.valueOf(value));
|
||||
return data;
|
||||
}
|
||||
|
||||
private String cacheValueToString(Object cacheValue)
|
||||
{
|
||||
if (cacheValue == null)
|
||||
{
|
||||
return StringUtils.EMPTY;
|
||||
}
|
||||
if (cacheValue instanceof String stringValue)
|
||||
{
|
||||
return stringValue;
|
||||
}
|
||||
return JSON.toJSONString(cacheValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,28 +78,5 @@ spring:
|
||||
wall:
|
||||
config:
|
||||
multi-statement-allow: true
|
||||
data:
|
||||
# redis 配置
|
||||
redis:
|
||||
# 地址
|
||||
host: 116.62.17.81
|
||||
# 端口,默认为6379
|
||||
port: 6379
|
||||
# 数据库索引
|
||||
database: 0
|
||||
# 密码
|
||||
password: Kfcx@1234
|
||||
# 连接超时时间
|
||||
timeout: 10s
|
||||
lettuce:
|
||||
pool:
|
||||
# 连接池中的最小空闲连接
|
||||
min-idle: 0
|
||||
# 连接池中的最大空闲连接
|
||||
max-idle: 8
|
||||
# 连接池的最大数据库连接数
|
||||
max-active: 8
|
||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-wait: -1ms
|
||||
model:
|
||||
url: http://localhost:8080/rate/pricing/mock/invokeModel
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.web.controller.monitor;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
|
||||
class CacheControllerTest
|
||||
{
|
||||
@Test
|
||||
void shouldReturnInMemoryCacheSummary() throws Exception
|
||||
{
|
||||
RedisCache redisCache = new RedisCache(new InMemoryCacheStore());
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new CacheController(redisCache)).build();
|
||||
|
||||
mockMvc.perform(get("/monitor/cache"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.info.cache_type").value("IN_MEMORY"))
|
||||
.andExpect(jsonPath("$.data.info.cache_mode").value("single-instance"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClearCacheKeysByPrefix() throws Exception
|
||||
{
|
||||
RedisCache redisCache = new RedisCache(new InMemoryCacheStore());
|
||||
redisCache.setCacheObject("login_tokens:a", "A");
|
||||
redisCache.setCacheObject("login_tokens:b", "B");
|
||||
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new CacheController(redisCache)).build();
|
||||
|
||||
mockMvc.perform(delete("/monitor/cache/clearCacheName/login_tokens:"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/monitor/cache/getKeys/login_tokens:"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -89,18 +89,6 @@
|
||||
<artifactId>jaxb-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- redis 缓存操作 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- pool 对象池 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-pool2</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 解析客户端操作系统、浏览器等 -->
|
||||
<dependency>
|
||||
<groupId>nl.basjes.parse.useragent</groupId>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.ruoyi.common.core.cache;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class InMemoryCacheStore
|
||||
{
|
||||
private final ConcurrentHashMap<String, InMemoryCacheEntry> entries = new ConcurrentHashMap<>();
|
||||
@@ -21,10 +28,11 @@ public class InMemoryCacheStore
|
||||
|
||||
public void set(String key, Object value, long timeout, TimeUnit unit)
|
||||
{
|
||||
long expireAtMillis = System.currentTimeMillis() + unit.toMillis(timeout);
|
||||
long expireAtMillis = System.currentTimeMillis() + Math.max(0L, unit.toMillis(timeout));
|
||||
putEntry(key, new InMemoryCacheEntry(value, expireAtMillis));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T get(String key)
|
||||
{
|
||||
InMemoryCacheEntry entry = readEntry(key);
|
||||
@@ -41,6 +49,16 @@ public class InMemoryCacheStore
|
||||
return entries.remove(key) != null;
|
||||
}
|
||||
|
||||
public boolean delete(Collection<String> keys)
|
||||
{
|
||||
boolean deleted = false;
|
||||
for (String key : keys)
|
||||
{
|
||||
deleted = delete(key) || deleted;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Set<String> keys(String pattern)
|
||||
{
|
||||
purgeExpiredEntries();
|
||||
@@ -54,6 +72,63 @@ public class InMemoryCacheStore
|
||||
return matchedKeys;
|
||||
}
|
||||
|
||||
public boolean expire(String key, long timeout, TimeUnit unit)
|
||||
{
|
||||
Objects.requireNonNull(unit, "TimeUnit must not be null");
|
||||
long expireAtMillis = System.currentTimeMillis() + Math.max(0L, unit.toMillis(timeout));
|
||||
return entries.computeIfPresent(key, (cacheKey, entry) -> entry.isExpired(System.currentTimeMillis())
|
||||
? null
|
||||
: new InMemoryCacheEntry(entry.value(), expireAtMillis)) != null;
|
||||
}
|
||||
|
||||
public long getExpire(String key)
|
||||
{
|
||||
return getExpire(key, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public long getExpire(String key, TimeUnit unit)
|
||||
{
|
||||
InMemoryCacheEntry entry = readEntry(key);
|
||||
if (entry == null)
|
||||
{
|
||||
return -2L;
|
||||
}
|
||||
if (entry.expireAtMillis() == null)
|
||||
{
|
||||
return -1L;
|
||||
}
|
||||
long remainingMillis = Math.max(0L, entry.expireAtMillis() - System.currentTimeMillis());
|
||||
long unitMillis = Math.max(1L, unit.toMillis(1));
|
||||
return (remainingMillis + unitMillis - 1) / unitMillis;
|
||||
}
|
||||
|
||||
public long increment(String key, long timeout, TimeUnit unit)
|
||||
{
|
||||
Objects.requireNonNull(unit, "TimeUnit must not be null");
|
||||
AtomicLong result = new AtomicLong();
|
||||
entries.compute(key, (cacheKey, currentEntry) -> {
|
||||
long now = System.currentTimeMillis();
|
||||
boolean missingOrExpired = currentEntry == null || currentEntry.isExpired(now);
|
||||
long nextValue = missingOrExpired ? 1L : toLong(currentEntry.value()) + 1L;
|
||||
Long expireAtMillis = missingOrExpired || currentEntry.expireAtMillis() == null
|
||||
? now + Math.max(0L, unit.toMillis(timeout))
|
||||
: currentEntry.expireAtMillis();
|
||||
if (missingOrExpired && currentEntry != null)
|
||||
{
|
||||
expiredCount.incrementAndGet();
|
||||
}
|
||||
result.set(nextValue);
|
||||
return new InMemoryCacheEntry(nextValue, expireAtMillis);
|
||||
});
|
||||
writeCount.incrementAndGet();
|
||||
return result.get();
|
||||
}
|
||||
|
||||
public void clear()
|
||||
{
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
public InMemoryCacheStats snapshot()
|
||||
{
|
||||
purgeExpiredEntries();
|
||||
@@ -67,6 +142,64 @@ public class InMemoryCacheStore
|
||||
writeCount.get());
|
||||
}
|
||||
|
||||
public <T> Map<String, T> getMap(String key)
|
||||
{
|
||||
Map<String, T> value = get(key);
|
||||
return value == null ? null : new HashMap<>(value);
|
||||
}
|
||||
|
||||
public <T> void putMap(String key, Map<String, T> dataMap)
|
||||
{
|
||||
set(key, new HashMap<>(dataMap));
|
||||
}
|
||||
|
||||
public <T> void putMapValue(String key, String mapKey, T value)
|
||||
{
|
||||
long ttl = getExpire(key, TimeUnit.MILLISECONDS);
|
||||
Map<String, T> current = getMap(key);
|
||||
if (current == null)
|
||||
{
|
||||
current = new HashMap<>();
|
||||
}
|
||||
current.put(mapKey, value);
|
||||
setWithOptionalTtl(key, current, ttl);
|
||||
}
|
||||
|
||||
public boolean deleteMapValue(String key, String mapKey)
|
||||
{
|
||||
long ttl = getExpire(key, TimeUnit.MILLISECONDS);
|
||||
Map<String, Object> current = getMap(key);
|
||||
if (current == null || !current.containsKey(mapKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
current.remove(mapKey);
|
||||
setWithOptionalTtl(key, current, ttl);
|
||||
return true;
|
||||
}
|
||||
|
||||
public <T> Set<T> getSet(String key)
|
||||
{
|
||||
Set<T> value = get(key);
|
||||
return value == null ? null : new HashSet<>(value);
|
||||
}
|
||||
|
||||
public <T> void putSet(String key, Set<T> dataSet)
|
||||
{
|
||||
set(key, new HashSet<>(dataSet));
|
||||
}
|
||||
|
||||
public <T> java.util.List<T> getList(String key)
|
||||
{
|
||||
java.util.List<T> value = get(key);
|
||||
return value == null ? null : new java.util.ArrayList<>(value);
|
||||
}
|
||||
|
||||
public <T> void putList(String key, java.util.List<T> dataList)
|
||||
{
|
||||
set(key, new java.util.ArrayList<>(dataList));
|
||||
}
|
||||
|
||||
private void putEntry(String key, InMemoryCacheEntry entry)
|
||||
{
|
||||
entries.put(key, entry);
|
||||
@@ -122,4 +255,23 @@ public class InMemoryCacheStore
|
||||
}
|
||||
return key.equals(pattern);
|
||||
}
|
||||
|
||||
private long toLong(Object value)
|
||||
{
|
||||
if (value instanceof Number number)
|
||||
{
|
||||
return number.longValue();
|
||||
}
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void setWithOptionalTtl(String key, Object value, long ttlMillis)
|
||||
{
|
||||
if (ttlMillis > 0)
|
||||
{
|
||||
set(key, value, ttlMillis, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +1,169 @@
|
||||
package com.ruoyi.common.core.redis;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.BoundSetOperations;
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStats;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
|
||||
/**
|
||||
* spring redis 工具类
|
||||
* 本地缓存门面,保留原有 RedisCache 业务入口。
|
||||
*
|
||||
* @author ruoyi
|
||||
**/
|
||||
@SuppressWarnings(value = { "unchecked", "rawtypes" })
|
||||
@SuppressWarnings("unchecked")
|
||||
@Component
|
||||
public class RedisCache
|
||||
{
|
||||
@Autowired
|
||||
public RedisTemplate redisTemplate;
|
||||
private final InMemoryCacheStore cacheStore;
|
||||
|
||||
public RedisCache(InMemoryCacheStore cacheStore)
|
||||
{
|
||||
this.cacheStore = cacheStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存基本的对象,Integer、String、实体类等
|
||||
*
|
||||
* @param key 缓存的键值
|
||||
* @param value 缓存的值
|
||||
*/
|
||||
public <T> void setCacheObject(final String key, final T value)
|
||||
{
|
||||
redisTemplate.opsForValue().set(key, value);
|
||||
cacheStore.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存基本的对象,Integer、String、实体类等
|
||||
*
|
||||
* @param key 缓存的键值
|
||||
* @param value 缓存的值
|
||||
* @param timeout 时间
|
||||
* @param timeUnit 时间颗粒度
|
||||
*/
|
||||
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
|
||||
{
|
||||
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
|
||||
cacheStore.set(key, value, timeout.longValue(), timeUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置有效时间
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param timeout 超时时间
|
||||
* @return true=设置成功;false=设置失败
|
||||
*/
|
||||
public boolean expire(final String key, final long timeout)
|
||||
{
|
||||
return expire(key, timeout, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置有效时间
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param timeout 超时时间
|
||||
* @param unit 时间单位
|
||||
* @return true=设置成功;false=设置失败
|
||||
*/
|
||||
public boolean expire(final String key, final long timeout, final TimeUnit unit)
|
||||
{
|
||||
return redisTemplate.expire(key, timeout, unit);
|
||||
return cacheStore.expire(key, timeout, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效时间
|
||||
*
|
||||
* @param key Redis键
|
||||
* @return 有效时间
|
||||
*/
|
||||
public long getExpire(final String key)
|
||||
{
|
||||
return redisTemplate.getExpire(key);
|
||||
return cacheStore.getExpire(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 key是否存在
|
||||
*
|
||||
* @param key 键
|
||||
* @return true 存在 false不存在
|
||||
*/
|
||||
public Boolean hasKey(String key)
|
||||
{
|
||||
return redisTemplate.hasKey(key);
|
||||
return cacheStore.hasKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得缓存的基本对象。
|
||||
*
|
||||
* @param key 缓存键值
|
||||
* @return 缓存键值对应的数据
|
||||
*/
|
||||
public <T> T getCacheObject(final String key)
|
||||
{
|
||||
ValueOperations<String, T> operation = redisTemplate.opsForValue();
|
||||
return operation.get(key);
|
||||
return cacheStore.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个对象
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
public boolean deleteObject(final String key)
|
||||
{
|
||||
return redisTemplate.delete(key);
|
||||
return cacheStore.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除集合对象
|
||||
*
|
||||
* @param collection 多个对象
|
||||
* @return
|
||||
*/
|
||||
public boolean deleteObject(final Collection collection)
|
||||
{
|
||||
return redisTemplate.delete(collection) > 0;
|
||||
if (collection == null || collection.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
List<String> keys = new ArrayList<>(collection.size());
|
||||
for (Object item : collection)
|
||||
{
|
||||
keys.add(String.valueOf(item));
|
||||
}
|
||||
return cacheStore.delete(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存List数据
|
||||
*
|
||||
* @param key 缓存的键值
|
||||
* @param dataList 待缓存的List数据
|
||||
* @return 缓存的对象
|
||||
*/
|
||||
public <T> long setCacheList(final String key, final List<T> dataList)
|
||||
{
|
||||
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
|
||||
return count == null ? 0 : count;
|
||||
cacheStore.putList(key, dataList);
|
||||
return dataList == null ? 0 : dataList.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得缓存的list对象
|
||||
*
|
||||
* @param key 缓存的键值
|
||||
* @return 缓存键值对应的数据
|
||||
*/
|
||||
public <T> List<T> getCacheList(final String key)
|
||||
{
|
||||
return redisTemplate.opsForList().range(key, 0, -1);
|
||||
return cacheStore.getList(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存Set
|
||||
*
|
||||
* @param key 缓存键值
|
||||
* @param dataSet 缓存的数据
|
||||
* @return 缓存数据的对象
|
||||
*/
|
||||
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
|
||||
public <T> long setCacheSet(final String key, final Set<T> dataSet)
|
||||
{
|
||||
BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
|
||||
Iterator<T> it = dataSet.iterator();
|
||||
while (it.hasNext())
|
||||
{
|
||||
setOperation.add(it.next());
|
||||
}
|
||||
return setOperation;
|
||||
cacheStore.putSet(key, dataSet);
|
||||
return dataSet == null ? 0 : dataSet.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得缓存的set
|
||||
*
|
||||
* @param key
|
||||
* @return
|
||||
*/
|
||||
public <T> Set<T> getCacheSet(final String key)
|
||||
{
|
||||
return redisTemplate.opsForSet().members(key);
|
||||
return cacheStore.getSet(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存Map
|
||||
*
|
||||
* @param key
|
||||
* @param dataMap
|
||||
*/
|
||||
public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
|
||||
{
|
||||
if (dataMap != null) {
|
||||
redisTemplate.opsForHash().putAll(key, dataMap);
|
||||
if (dataMap != null)
|
||||
{
|
||||
cacheStore.putMap(key, dataMap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得缓存的Map
|
||||
*
|
||||
* @param key
|
||||
* @return
|
||||
*/
|
||||
public <T> Map<String, T> getCacheMap(final String key)
|
||||
{
|
||||
return redisTemplate.opsForHash().entries(key);
|
||||
return cacheStore.getMap(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 往Hash中存入数据
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param hKey Hash键
|
||||
* @param value 值
|
||||
*/
|
||||
public <T> void setCacheMapValue(final String key, final String hKey, final T value)
|
||||
{
|
||||
redisTemplate.opsForHash().put(key, hKey, value);
|
||||
cacheStore.putMapValue(key, hKey, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Hash中的数据
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param hKey Hash键
|
||||
* @return Hash中的对象
|
||||
*/
|
||||
public <T> T getCacheMapValue(final String key, final String hKey)
|
||||
{
|
||||
HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
|
||||
return opsForHash.get(key, hKey);
|
||||
Map<String, T> map = cacheStore.getMap(key);
|
||||
return map == null ? null : map.get(hKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个Hash中的数据
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param hKeys Hash键集合
|
||||
* @return Hash对象集合
|
||||
*/
|
||||
public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
|
||||
{
|
||||
return redisTemplate.opsForHash().multiGet(key, hKeys);
|
||||
Map<String, T> map = cacheStore.getMap(key);
|
||||
if (map == null || hKeys == null || hKeys.isEmpty())
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<T> values = new ArrayList<>(hKeys.size());
|
||||
for (Object hKey : hKeys)
|
||||
{
|
||||
values.add(map.get(String.valueOf(hKey)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Hash中的某条数据
|
||||
*
|
||||
* @param key Redis键
|
||||
* @param hKey Hash键
|
||||
* @return 是否成功
|
||||
*/
|
||||
public boolean deleteCacheMapValue(final String key, final String hKey)
|
||||
{
|
||||
return redisTemplate.opsForHash().delete(key, hKey) > 0;
|
||||
return cacheStore.deleteMapValue(key, hKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得缓存的基本对象列表
|
||||
*
|
||||
* @param pattern 字符串前缀
|
||||
* @return 对象列表
|
||||
*/
|
||||
public Collection<String> keys(final String pattern)
|
||||
{
|
||||
return redisTemplate.keys(pattern);
|
||||
return cacheStore.keys(pattern);
|
||||
}
|
||||
|
||||
public long increment(String key, long timeout, TimeUnit unit)
|
||||
{
|
||||
return cacheStore.increment(key, timeout, unit);
|
||||
}
|
||||
|
||||
public InMemoryCacheStats getCacheStats()
|
||||
{
|
||||
return cacheStore.snapshot();
|
||||
}
|
||||
|
||||
public void clear()
|
||||
{
|
||||
cacheStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.common.constant.CacheConstants;
|
||||
import com.ruoyi.common.core.domain.entity.SysDictData;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
@@ -41,10 +41,14 @@ public class DictUtils
|
||||
*/
|
||||
public static List<SysDictData> getDictCache(String key)
|
||||
{
|
||||
JSONArray arrayCache = SpringUtils.getBean(RedisCache.class).getCacheObject(getCacheKey(key));
|
||||
if (StringUtils.isNotNull(arrayCache))
|
||||
Object cacheObject = SpringUtils.getBean(RedisCache.class).getCacheObject(getCacheKey(key));
|
||||
if (cacheObject instanceof List<?> listCache)
|
||||
{
|
||||
return arrayCache.toList(SysDictData.class);
|
||||
return (List<SysDictData>) listCache;
|
||||
}
|
||||
if (StringUtils.isNotNull(cacheObject))
|
||||
{
|
||||
return JSON.parseArray(JSON.toJSONString(cacheObject), SysDictData.class);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.common.core.redis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisCacheTest
|
||||
{
|
||||
@Test
|
||||
void shouldSupportSetGetDeleteAndExpireThroughRedisCacheFacade()
|
||||
{
|
||||
RedisCache cache = new RedisCache(new InMemoryCacheStore());
|
||||
cache.setCacheObject("login_tokens:abc", "payload", 1, TimeUnit.SECONDS);
|
||||
assertEquals("payload", cache.getCacheObject("login_tokens:abc"));
|
||||
assertTrue(cache.hasKey("login_tokens:abc"));
|
||||
assertTrue(cache.deleteObject("login_tokens:abc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSupportKeysBulkDeleteAndRemainingTtl()
|
||||
{
|
||||
RedisCache cache = new RedisCache(new InMemoryCacheStore());
|
||||
cache.setCacheObject("login_tokens:a", "A", 5, TimeUnit.SECONDS);
|
||||
cache.setCacheObject("login_tokens:b", "B", 5, TimeUnit.SECONDS);
|
||||
cache.setCacheObject("sys_dict:x", "X");
|
||||
|
||||
assertNotNull(cache.getExpire("login_tokens:a"));
|
||||
assertTrue(cache.getExpire("login_tokens:a") > 0);
|
||||
assertEquals(Set.of("login_tokens:a", "login_tokens:b"), Set.copyOf(cache.keys("login_tokens:*")));
|
||||
assertTrue(cache.deleteObject(List.of("login_tokens:a", "login_tokens:b")));
|
||||
assertFalse(cache.hasKey("login_tokens:a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIncrementCounterWithinTtlWindow() throws Exception
|
||||
{
|
||||
RedisCache cache = new RedisCache(new InMemoryCacheStore());
|
||||
assertEquals(1L, cache.increment("rate_limit:test", 50, TimeUnit.MILLISECONDS));
|
||||
assertEquals(2L, cache.increment("rate_limit:test", 50, TimeUnit.MILLISECONDS));
|
||||
Thread.sleep(70);
|
||||
assertNull(cache.getCacheObject("rate_limit:test"));
|
||||
assertEquals(1L, cache.increment("rate_limit:test", 50, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.common.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
import com.ruoyi.common.core.domain.entity.SysDictData;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.spring.SpringUtils;
|
||||
|
||||
class DictUtilsTest
|
||||
{
|
||||
@AfterEach
|
||||
void clearSpringUtils()
|
||||
{
|
||||
ReflectionTestUtils.setField(SpringUtils.class, "beanFactory", null);
|
||||
ReflectionTestUtils.setField(SpringUtils.class, "applicationContext", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReadDictListFromLocalCache()
|
||||
{
|
||||
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
|
||||
beanFactory.registerSingleton("redisCache", new RedisCache(new InMemoryCacheStore()));
|
||||
ReflectionTestUtils.setField(SpringUtils.class, "beanFactory", beanFactory);
|
||||
|
||||
SysDictData dictData = new SysDictData();
|
||||
dictData.setDictLabel("正常");
|
||||
dictData.setDictValue("0");
|
||||
|
||||
DictUtils.setDictCache("sys_normal_disable", List.of(dictData));
|
||||
|
||||
List<SysDictData> cached = DictUtils.getDictCache("sys_normal_disable");
|
||||
assertEquals(1, cached.size());
|
||||
assertEquals("正常", cached.get(0).getDictLabel());
|
||||
|
||||
DictUtils.clearDictCache();
|
||||
assertNull(DictUtils.getDictCache("sys_normal_disable"));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@
|
||||
<artifactId>ruoyi-system</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
package com.ruoyi.framework.aspectj;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.ruoyi.common.annotation.RateLimiter;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.enums.LimitType;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.ip.IpUtils;
|
||||
|
||||
/**
|
||||
@@ -30,20 +26,11 @@ public class RateLimiterAspect
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
|
||||
|
||||
private RedisTemplate<Object, Object> redisTemplate;
|
||||
private final RedisCache redisCache;
|
||||
|
||||
private RedisScript<Long> limitScript;
|
||||
|
||||
@Autowired
|
||||
public void setRedisTemplate1(RedisTemplate<Object, Object> redisTemplate)
|
||||
public RateLimiterAspect(RedisCache redisCache)
|
||||
{
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setLimitScript(RedisScript<Long> limitScript)
|
||||
{
|
||||
this.limitScript = limitScript;
|
||||
this.redisCache = redisCache;
|
||||
}
|
||||
|
||||
@Before("@annotation(rateLimiter)")
|
||||
@@ -53,15 +40,14 @@ public class RateLimiterAspect
|
||||
int count = rateLimiter.count();
|
||||
|
||||
String combineKey = getCombineKey(rateLimiter, point);
|
||||
List<Object> keys = Collections.singletonList(combineKey);
|
||||
try
|
||||
{
|
||||
Long number = redisTemplate.execute(limitScript, keys, count, time);
|
||||
if (StringUtils.isNull(number) || number.intValue() > count)
|
||||
long number = redisCache.increment(combineKey, time, TimeUnit.SECONDS);
|
||||
if (number > count)
|
||||
{
|
||||
throw new ServiceException("访问过于频繁,请稍候再试");
|
||||
}
|
||||
log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), combineKey);
|
||||
log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number, combineKey);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.ruoyi.framework.config;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.serializer.SerializationException;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONReader;
|
||||
import com.alibaba.fastjson2.JSONWriter;
|
||||
import com.alibaba.fastjson2.filter.Filter;
|
||||
import com.ruoyi.common.constant.Constants;
|
||||
|
||||
/**
|
||||
* Redis使用FastJson序列化
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
|
||||
{
|
||||
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
|
||||
|
||||
static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR);
|
||||
|
||||
private Class<T> clazz;
|
||||
|
||||
public FastJson2JsonRedisSerializer(Class<T> clazz)
|
||||
{
|
||||
super();
|
||||
this.clazz = clazz;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] serialize(T t) throws SerializationException
|
||||
{
|
||||
if (t == null)
|
||||
{
|
||||
return new byte[0];
|
||||
}
|
||||
return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T deserialize(byte[] bytes) throws SerializationException
|
||||
{
|
||||
if (bytes == null || bytes.length <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String str = new String(bytes, DEFAULT_CHARSET);
|
||||
|
||||
return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.ruoyi.framework.config;
|
||||
|
||||
import org.springframework.cache.annotation.CachingConfigurerSupport;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
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.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* redis配置
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class RedisConfig extends CachingConfigurerSupport
|
||||
{
|
||||
@Bean
|
||||
@SuppressWarnings(value = { "unchecked", "rawtypes" })
|
||||
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
|
||||
{
|
||||
RedisTemplate<Object, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
|
||||
|
||||
// 使用StringRedisSerializer来序列化和反序列化redis的key值
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setValueSerializer(serializer);
|
||||
|
||||
// Hash的key也采用StringRedisSerializer的序列化方式
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
template.setHashValueSerializer(serializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DefaultRedisScript<Long> limitScript()
|
||||
{
|
||||
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
|
||||
redisScript.setScriptText(limitScriptText());
|
||||
redisScript.setResultType(Long.class);
|
||||
return redisScript;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流脚本
|
||||
*/
|
||||
private String limitScriptText()
|
||||
{
|
||||
return "local key = KEYS[1]\n" +
|
||||
"local count = tonumber(ARGV[1])\n" +
|
||||
"local time = tonumber(ARGV[2])\n" +
|
||||
"local current = redis.call('get', key);\n" +
|
||||
"if current and tonumber(current) > count then\n" +
|
||||
" return tonumber(current);\n" +
|
||||
"end\n" +
|
||||
"current = redis.call('incr', key)\n" +
|
||||
"if tonumber(current) == 1 then\n" +
|
||||
" redis.call('expire', key, time)\n" +
|
||||
"end\n" +
|
||||
"return tonumber(current);";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.framework.aspectj;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.ruoyi.common.annotation.RateLimiter;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
class RateLimiterAspectTest
|
||||
{
|
||||
@Test
|
||||
void shouldRejectThirdRequestWithinWindow() throws Throwable
|
||||
{
|
||||
RateLimiterAspect aspect = new RateLimiterAspect(new RedisCache(new InMemoryCacheStore()));
|
||||
JoinPoint joinPoint = mockJoinPoint(TestRateLimitTarget.class.getDeclaredMethod("limited"));
|
||||
RateLimiter rateLimiter = TestRateLimitTarget.class.getDeclaredMethod("limited").getAnnotation(RateLimiter.class);
|
||||
|
||||
assertDoesNotThrow(() -> aspect.doBefore(joinPoint, rateLimiter));
|
||||
assertDoesNotThrow(() -> aspect.doBefore(joinPoint, rateLimiter));
|
||||
assertThrows(ServiceException.class, () -> aspect.doBefore(joinPoint, rateLimiter));
|
||||
}
|
||||
|
||||
private JoinPoint mockJoinPoint(Method method)
|
||||
{
|
||||
MethodSignature signature = mock(MethodSignature.class);
|
||||
when(signature.getMethod()).thenReturn(method);
|
||||
|
||||
JoinPoint joinPoint = mock(JoinPoint.class);
|
||||
when(joinPoint.getSignature()).thenReturn((Signature) signature);
|
||||
return joinPoint;
|
||||
}
|
||||
|
||||
static class TestRateLimitTarget
|
||||
{
|
||||
@RateLimiter(count = 2, time = 60)
|
||||
public void limited()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ruoyi.framework.web.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import com.ruoyi.common.annotation.RepeatSubmit;
|
||||
import com.ruoyi.common.constant.CacheConstants;
|
||||
import com.ruoyi.common.core.cache.InMemoryCacheStore;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.framework.interceptor.impl.SameUrlDataInterceptor;
|
||||
import com.ruoyi.system.service.ISysConfigService;
|
||||
|
||||
class TokenServiceLocalCacheTest
|
||||
{
|
||||
@AfterEach
|
||||
void clearRequestContext()
|
||||
{
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStoreLoginUserWithTokenTtl()
|
||||
{
|
||||
RedisCache redisCache = new RedisCache(new InMemoryCacheStore());
|
||||
TokenService tokenService = new TokenService();
|
||||
ReflectionTestUtils.setField(tokenService, "redisCache", redisCache);
|
||||
ReflectionTestUtils.setField(tokenService, "header", "Authorization");
|
||||
ReflectionTestUtils.setField(tokenService, "secret", "unit-test-secret");
|
||||
ReflectionTestUtils.setField(tokenService, "expireTime", 30);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
request.addHeader("User-Agent", "Mozilla/5.0");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
SysUser user = new SysUser();
|
||||
user.setUserName("admin");
|
||||
user.setPassword("password");
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUser(user);
|
||||
|
||||
String jwt = tokenService.createToken(loginUser);
|
||||
|
||||
assertNotNull(jwt);
|
||||
assertNotNull(redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + loginUser.getToken()));
|
||||
assertTrue(redisCache.getExpire(CacheConstants.LOGIN_TOKEN_KEY + loginUser.getToken()) > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteCaptchaAfterValidation()
|
||||
{
|
||||
RedisCache redisCache = new RedisCache(new InMemoryCacheStore());
|
||||
SysLoginService loginService = new SysLoginService();
|
||||
ISysConfigService configService = mock(ISysConfigService.class);
|
||||
when(configService.selectCaptchaEnabled()).thenReturn(true);
|
||||
ReflectionTestUtils.setField(loginService, "redisCache", redisCache);
|
||||
ReflectionTestUtils.setField(loginService, "configService", configService);
|
||||
|
||||
redisCache.setCacheObject(CacheConstants.CAPTCHA_CODE_KEY + "uuid", "ABCD", 1, TimeUnit.MINUTES);
|
||||
|
||||
loginService.validateCaptcha("admin", "ABCD", "uuid");
|
||||
|
||||
assertFalse(redisCache.hasKey(CacheConstants.CAPTCHA_CODE_KEY + "uuid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSupportMillisecondRepeatSubmitWindow() throws Exception
|
||||
{
|
||||
RedisCache redisCache = new RedisCache(new InMemoryCacheStore());
|
||||
SameUrlDataInterceptor interceptor = new SameUrlDataInterceptor();
|
||||
ReflectionTestUtils.setField(interceptor, "redisCache", redisCache);
|
||||
ReflectionTestUtils.setField(interceptor, "header", "Authorization");
|
||||
|
||||
RepeatSubmit repeatSubmit = RepeatSubmitTarget.class.getDeclaredMethod("submit")
|
||||
.getAnnotation(RepeatSubmit.class);
|
||||
|
||||
MockHttpServletRequest firstRequest = createRepeatRequest();
|
||||
assertFalse(interceptor.isRepeatSubmit(firstRequest, repeatSubmit));
|
||||
|
||||
MockHttpServletRequest secondRequest = createRepeatRequest();
|
||||
assertTrue(interceptor.isRepeatSubmit(secondRequest, repeatSubmit));
|
||||
|
||||
Thread.sleep(70);
|
||||
MockHttpServletRequest thirdRequest = createRepeatRequest();
|
||||
assertFalse(interceptor.isRepeatSubmit(thirdRequest, repeatSubmit));
|
||||
}
|
||||
|
||||
private MockHttpServletRequest createRepeatRequest()
|
||||
{
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/loan/pricing");
|
||||
request.addHeader("Authorization", "token-1");
|
||||
request.addParameter("loanId", "1001");
|
||||
return request;
|
||||
}
|
||||
|
||||
static class RepeatSubmitTarget
|
||||
{
|
||||
@RepeatSubmit(interval = 50)
|
||||
public void submit()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user