diff --git a/doc/implementation-report-2026-03-28-remove-redis-backend.md b/doc/implementation-report-2026-03-28-remove-redis-backend.md index c1916fa..37bc4b6 100644 --- a/doc/implementation-report-2026-03-28-remove-redis-backend.md +++ b/doc/implementation-report-2026-03-28-remove-redis-backend.md @@ -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` 的兼容 +- 删除 `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 进程 diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 2981897..84c510f 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -60,6 +60,12 @@ ruoyi-loan-pricing + + org.springframework.boot + spring-boot-starter-test + test + + @@ -92,4 +98,4 @@ ${project.artifactId} - \ No newline at end of file + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java index d17bd66..854bdcc 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java @@ -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 redisTemplate; + private final RedisCache redisCache; private final static List caches = new ArrayList(); { @@ -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) connection -> connection.info()); - Properties commandStats = (Properties) redisTemplate.execute((RedisCallback) connection -> connection.info("commandstats")); - Object dbSize = redisTemplate.execute((RedisCallback) connection -> connection.dbSize()); + InMemoryCacheStats stats = redisCache.getCacheStats(); + Map 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 result = new HashMap<>(3); result.put("info", info); - result.put("dbSize", dbSize); + result.put("dbSize", stats.keySize()); List> pieList = new ArrayList<>(); - commandStats.stringPropertyNames().forEach(key -> { - Map 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 cacheKeys = redisTemplate.keys(cacheName + "*"); - return AjaxResult.success(new TreeSet<>(cacheKeys)); + Set 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 cacheKeys = redisTemplate.keys(cacheName + "*"); - redisTemplate.delete(cacheKeys); + Collection 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 cacheKeys = redisTemplate.keys("*"); - redisTemplate.delete(cacheKeys); + redisCache.clear(); return AjaxResult.success(); } + + private Map statEntry(String name, long value) + { + Map 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); + } } diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index ad64a4c..1b246ad 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -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 diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/monitor/CacheControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/monitor/CacheControllerTest.java new file mode 100644 index 0000000..74e5f7f --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/monitor/CacheControllerTest.java @@ -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()); + } +} diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index f81f884..3e57758 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -89,18 +89,6 @@ jaxb-api - - - org.springframework.boot - spring-boot-starter-data-redis - - - - - org.apache.commons - commons-pool2 - - nl.basjes.parse.useragent diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java index e19d5c2..e7a66d2 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java @@ -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 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 get(String key) { InMemoryCacheEntry entry = readEntry(key); @@ -41,6 +49,16 @@ public class InMemoryCacheStore return entries.remove(key) != null; } + public boolean delete(Collection keys) + { + boolean deleted = false; + for (String key : keys) + { + deleted = delete(key) || deleted; + } + return deleted; + } + public Set 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 Map getMap(String key) + { + Map value = get(key); + return value == null ? null : new HashMap<>(value); + } + + public void putMap(String key, Map dataMap) + { + set(key, new HashMap<>(dataMap)); + } + + public void putMapValue(String key, String mapKey, T value) + { + long ttl = getExpire(key, TimeUnit.MILLISECONDS); + Map 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 current = getMap(key); + if (current == null || !current.containsKey(mapKey)) + { + return false; + } + current.remove(mapKey); + setWithOptionalTtl(key, current, ttl); + return true; + } + + public Set getSet(String key) + { + Set value = get(key); + return value == null ? null : new HashSet<>(value); + } + + public void putSet(String key, Set dataSet) + { + set(key, new HashSet<>(dataSet)); + } + + public java.util.List getList(String key) + { + java.util.List value = get(key); + return value == null ? null : new java.util.ArrayList<>(value); + } + + public void putList(String key, java.util.List 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); + } } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisCache.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisCache.java index 44e80d8..984bbf8 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisCache.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisCache.java @@ -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 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 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 getCacheObject(final String key) { - ValueOperations 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 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 long setCacheList(final String key, final List 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 List getCacheList(final String key) { - return redisTemplate.opsForList().range(key, 0, -1); + return cacheStore.getList(key); } - /** - * 缓存Set - * - * @param key 缓存键值 - * @param dataSet 缓存的数据 - * @return 缓存数据的对象 - */ - public BoundSetOperations setCacheSet(final String key, final Set dataSet) + public long setCacheSet(final String key, final Set dataSet) { - BoundSetOperations setOperation = redisTemplate.boundSetOps(key); - Iterator 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 Set getCacheSet(final String key) { - return redisTemplate.opsForSet().members(key); + return cacheStore.getSet(key); } - /** - * 缓存Map - * - * @param key - * @param dataMap - */ public void setCacheMap(final String key, final Map dataMap) { - if (dataMap != null) { - redisTemplate.opsForHash().putAll(key, dataMap); + if (dataMap != null) + { + cacheStore.putMap(key, dataMap); } } - /** - * 获得缓存的Map - * - * @param key - * @return - */ public Map getCacheMap(final String key) { - return redisTemplate.opsForHash().entries(key); + return cacheStore.getMap(key); } - /** - * 往Hash中存入数据 - * - * @param key Redis键 - * @param hKey Hash键 - * @param value 值 - */ public 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 getCacheMapValue(final String key, final String hKey) { - HashOperations opsForHash = redisTemplate.opsForHash(); - return opsForHash.get(key, hKey); + Map map = cacheStore.getMap(key); + return map == null ? null : map.get(hKey); } - /** - * 获取多个Hash中的数据 - * - * @param key Redis键 - * @param hKeys Hash键集合 - * @return Hash对象集合 - */ public List getMultiCacheMapValue(final String key, final Collection hKeys) { - return redisTemplate.opsForHash().multiGet(key, hKeys); + Map map = cacheStore.getMap(key); + if (map == null || hKeys == null || hKeys.isEmpty()) + { + return Collections.emptyList(); + } + List 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 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(); } } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/DictUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DictUtils.java index 9d36bb7..8eddd8c 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/DictUtils.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DictUtils.java @@ -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 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) listCache; + } + if (StringUtils.isNotNull(cacheObject)) + { + return JSON.parseArray(JSON.toJSONString(cacheObject), SysDictData.class); } return null; } diff --git a/ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java b/ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java new file mode 100644 index 0000000..472393a --- /dev/null +++ b/ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java @@ -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)); + } +} diff --git a/ruoyi-common/src/test/java/com/ruoyi/common/utils/DictUtilsTest.java b/ruoyi-common/src/test/java/com/ruoyi/common/utils/DictUtilsTest.java new file mode 100644 index 0000000..e2b4d97 --- /dev/null +++ b/ruoyi-common/src/test/java/com/ruoyi/common/utils/DictUtilsTest.java @@ -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 cached = DictUtils.getDictCache("sys_normal_disable"); + assertEquals(1, cached.size()); + assertEquals("正常", cached.get(0).getDictLabel()); + + DictUtils.clearDictCache(); + assertNull(DictUtils.getDictCache("sys_normal_disable")); + } +} diff --git a/ruoyi-framework/pom.xml b/ruoyi-framework/pom.xml index 3c52080..51ced87 100644 --- a/ruoyi-framework/pom.xml +++ b/ruoyi-framework/pom.xml @@ -59,6 +59,12 @@ ruoyi-system + + org.springframework.boot + spring-boot-starter-test + test + + - \ No newline at end of file + diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java index b720bc1..e06b7a7 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java @@ -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 redisTemplate; + private final RedisCache redisCache; - private RedisScript limitScript; - - @Autowired - public void setRedisTemplate1(RedisTemplate redisTemplate) + public RateLimiterAspect(RedisCache redisCache) { - this.redisTemplate = redisTemplate; - } - - @Autowired - public void setLimitScript(RedisScript 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 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) { diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FastJson2JsonRedisSerializer.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FastJson2JsonRedisSerializer.java deleted file mode 100644 index 4adbb7f..0000000 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/FastJson2JsonRedisSerializer.java +++ /dev/null @@ -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 implements RedisSerializer -{ - public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); - - static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR); - - private Class clazz; - - public FastJson2JsonRedisSerializer(Class 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); - } -} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java deleted file mode 100644 index 3453237..0000000 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java +++ /dev/null @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) - { - RedisTemplate 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 limitScript() - { - DefaultRedisScript 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);"; - } -} diff --git a/ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java b/ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java new file mode 100644 index 0000000..ea5a2d1 --- /dev/null +++ b/ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java @@ -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() + { + } + } +} diff --git a/ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java b/ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java new file mode 100644 index 0000000..91d7ed6 --- /dev/null +++ b/ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java @@ -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() + { + } + } +}