From 36f3c32a48f0407913eb0d3c91a2bd1ddfe48f74 Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Wed, 15 Apr 2026 10:53:27 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4Redis=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E9=80=A0=E4=B8=BA=E5=86=85=E5=AD=98=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/restart_java_backend.sh | 264 ++++++++++++++++ ...4-15-前端缓存监控页适配内存缓存实施记录.md | 116 +++++++ ...移除Redis依赖改造为内存缓存后端实施记录.md | 131 ++++++++ ruoyi-admin/pom.xml | 22 +- .../controller/monitor/CacheController.java | 269 ++++++++-------- .../src/main/resources/application.yml | 38 +-- .../monitor/CacheControllerTest.java | 44 +++ ruoyi-common/pom.xml | 46 ++- .../common/core/cache/InMemoryCacheEntry.java | 29 ++ .../common/core/cache/InMemoryCacheStats.java | 65 ++++ .../common/core/cache/InMemoryCacheStore.java | 290 ++++++++++++++++++ .../ruoyi/common/core/redis/RedisCache.java | 267 +++++++++------- .../core/cache/InMemoryCacheStoreTest.java | 58 ++++ .../common/core/redis/RedisCacheTest.java | 56 ++++ ruoyi-framework/pom.xml | 24 +- .../framework/aspectj/RateLimiterAspect.java | 164 +++++----- .../config/FastJson2JsonRedisSerializer.java | 52 ---- .../ruoyi/framework/config/RedisConfig.java | 69 ----- .../aspectj/RateLimiterAspectTest.java | 50 +++ .../service/TokenServiceLocalCacheTest.java | 116 +++++++ ruoyi-ui/src/views/monitor/cache/index.vue | 283 ++++++++++++----- ruoyi-ui/src/views/monitor/cache/list.vue | 20 +- 22 files changed, 1864 insertions(+), 609 deletions(-) create mode 100755 bin/restart_java_backend.sh create mode 100644 doc/2026-04-15-前端缓存监控页适配内存缓存实施记录.md create mode 100644 doc/2026-04-15-移除Redis依赖改造为内存缓存后端实施记录.md create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/web/controller/monitor/CacheControllerTest.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java create mode 100644 ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java create mode 100644 ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java delete mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/config/FastJson2JsonRedisSerializer.java delete mode 100644 ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java create mode 100644 ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java create mode 100644 ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java diff --git a/bin/restart_java_backend.sh b/bin/restart_java_backend.sh new file mode 100755 index 0000000..3bcba2c --- /dev/null +++ b/bin/restart_java_backend.sh @@ -0,0 +1,264 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +LOG_DIR="$ROOT_DIR/logs" +CONSOLE_LOG="$LOG_DIR/backend-console.log" +PID_FILE="$LOG_DIR/backend-java.pid" +TARGET_DIR="$ROOT_DIR/ruoyi-admin/target" +JAR_NAME="ruoyi-admin.jar" +SERVER_PORT=63310 +STOP_WAIT_SECONDS=30 +APP_MARKER="-Dccdi.backend.root=$ROOT_DIR" +JAVA_OPTS="$APP_MARKER -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" + +timestamp() { + date "+%Y-%m-%d %H:%M:%S" +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +usage() { + cat <<'EOF' +用法: ./bin/restart_java_backend.sh [start|stop|restart|status] + +默认动作: + restart 重新构建后端并重启,随后持续输出运行日志 +EOF +} + +ensure_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +is_managed_backend_pid() { + pid="$1" + if [ -z "${pid:-}" ] || ! kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + args=$(ps -o args= -p "$pid" 2>/dev/null || true) + if [ -z "${args:-}" ]; then + return 1 + fi + + case "$args" in + *"$APP_MARKER"*"$JAR_NAME"*|*"$JAR_NAME"*"$APP_MARKER"*) + return 0 + ;; + esac + + if [ -f "$PID_FILE" ]; then + file_pid=$(cat "$PID_FILE" 2>/dev/null || true) + if [ "${file_pid:-}" = "$pid" ]; then + case "$args" in + *"java"*"-jar"*"$JAR_NAME"*) + return 0 + ;; + esac + fi + fi + + return 1 +} + +collect_pids() { + all_pids="" + + if [ -f "$PID_FILE" ]; then + file_pid=$(cat "$PID_FILE" 2>/dev/null || true) + if [ -n "${file_pid:-}" ] && is_managed_backend_pid "$file_pid"; then + all_pids="$all_pids $file_pid" + fi + fi + + marker_pids=$(ps -ef | awk -v marker="$APP_MARKER" -v jar="$JAR_NAME" ' + index($0, "") == 0 && index($0, marker) > 0 { + for (i = 1; i < NF; i++) { + if ($i == "-jar" && $(i + 1) == jar) { + print $2 + break + } + } + } + ' | xargs 2>/dev/null || true) + if [ -n "${marker_pids:-}" ]; then + for pid in $marker_pids; do + if is_managed_backend_pid "$pid"; then + all_pids="$all_pids $pid" + fi + done + fi + + unique_pids="" + for pid in $all_pids; do + case " $unique_pids " in + *" $pid "*) ;; + *) + unique_pids="$unique_pids $pid" + ;; + esac + done + + printf '%s\n' "$(echo "$unique_pids" | xargs 2>/dev/null || true)" +} + +build_backend() { + log_info "开始构建后端: mvn -pl ruoyi-admin -am clean package -DskipTests" + ( + cd "$ROOT_DIR" + mvn -pl ruoyi-admin -am clean package -DskipTests + ) +} + +stop_backend() { + pids=$(collect_pids) + + if [ -z "${pids:-}" ]; then + log_info "未发现运行中的后端进程" + rm -f "$PID_FILE" + return 0 + fi + + log_info "准备停止后端进程: $pids" + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + + remaining_pids="$pids" + elapsed=0 + while [ -n "${remaining_pids:-}" ] && [ "$elapsed" -lt "$STOP_WAIT_SECONDS" ]; do + sleep 1 + elapsed=$((elapsed + 1)) + remaining_pids="" + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + remaining_pids="$remaining_pids $pid" + fi + done + remaining_pids=$(echo "$remaining_pids" | xargs 2>/dev/null || true) + done + + if [ -n "${remaining_pids:-}" ]; then + log_info "仍有进程未退出,执行强制停止: $remaining_pids" + for pid in $remaining_pids; do + kill -KILL "$pid" 2>/dev/null || true + done + fi + + rm -f "$PID_FILE" + log_info "后端停止完成" +} + +start_backend() { + mkdir -p "$LOG_DIR" + touch "$CONSOLE_LOG" + + printf '\n===== %s restart =====\n' "$(timestamp)" >> "$CONSOLE_LOG" + + log_info "开始启动后端,控制台日志输出到: $CONSOLE_LOG" + if [ ! -f "$TARGET_DIR/$JAR_NAME" ]; then + log_error "未找到打包产物: $TARGET_DIR/$JAR_NAME" + exit 1 + fi + + ( + cd "$TARGET_DIR" + nohup java $JAVA_OPTS -jar "$JAR_NAME" >> "$CONSOLE_LOG" 2>&1 & + echo $! > "$PID_FILE" + ) + + sleep 3 + + starter_pid=$(cat "$PID_FILE" 2>/dev/null || true) + if [ -z "${starter_pid:-}" ] || ! kill -0 "$starter_pid" 2>/dev/null; then + log_error "启动命令未保持运行,请检查日志: $CONSOLE_LOG" + exit 1 + fi + + log_info "启动命令已提交,PID: $starter_pid" +} + +status_backend() { + pids=$(collect_pids) + if [ -n "${pids:-}" ]; then + log_info "后端正在运行,进程: $pids" + return 0 + fi + + port_pids=$(lsof -tiTCP:"$SERVER_PORT" -sTCP:LISTEN 2>/dev/null || true) + if [ -n "${port_pids:-}" ]; then + log_info "未发现脚本托管的后端进程,但端口 $SERVER_PORT 被其他进程占用: $port_pids" + else + log_info "后端未运行" + fi +} + +follow_logs() { + mkdir -p "$LOG_DIR" + touch "$CONSOLE_LOG" + log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看" + tail -n 200 -F "$CONSOLE_LOG" +} + +start_action() { + running_pids=$(collect_pids) + if [ -n "${running_pids:-}" ]; then + log_error "检测到已有后端进程在运行: $running_pids,请先执行 stop 或 restart" + exit 1 + fi + + build_backend + start_backend + follow_logs +} + +restart_action() { + build_backend + stop_backend + start_backend + follow_logs +} + +main() { + ensure_command mvn + ensure_command lsof + ensure_command ps + ensure_command tail + + action="${1:-restart}" + case "$action" in + start) + start_action + ;; + stop) + stop_backend + ;; + restart) + restart_action + ;; + status) + status_backend + ;; + -h|--help|help) + usage + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/doc/2026-04-15-前端缓存监控页适配内存缓存实施记录.md b/doc/2026-04-15-前端缓存监控页适配内存缓存实施记录.md new file mode 100644 index 0000000..b68de7f --- /dev/null +++ b/doc/2026-04-15-前端缓存监控页适配内存缓存实施记录.md @@ -0,0 +1,116 @@ +# 前端缓存监控页适配内存缓存实施记录 + +## 1. 实际改动内容 + +### 1.1 调整缓存监控首页 + +修改文件: + +- `ruoyi-ui/src/views/monitor/cache/index.vue` + +改动内容: + +- 页面展示从 Redis 服务器指标改为内存缓存统计 +- 增加默认数据结构: + - `cache_type` + - `cache_mode` + - `key_size` + - `hit_count` + - `miss_count` + - `expired_count` + - `write_count` +- 新增格式化与归一化方法: + - `normalizeCacheData` + - `normalizeCommandStats` + - `formatCacheType` + - `formatCacheMode` + - `formatSampleTime` + - `toNumber` +- 基本信息区改为展示: + - 缓存类型 + - 运行模式 + - 总键数 + - 写入次数 + - 命中次数 + - 未命中次数 + - 过期清理次数 + - 命中率 +- 图表改为展示内存缓存统计分布与条形概览 + +### 1.2 调整缓存列表页清理全部后的状态 + +修改文件: + +- `ruoyi-ui/src/views/monitor/cache/list.vue` + +改动内容: + +- 清理全部缓存后主动清空: + - `cacheKeys` + - `cacheForm` + - `nowCacheName` +- 重新加载缓存分类列表,避免页面残留旧数据显示 + +### 1.3 API 文件 + +文件: + +- `ruoyi-ui/src/api/monitor/cache.js` + +结论: + +- 接口路径与交互方式保持不变 +- 本次未对 API 文件做额外改动 + +## 2. 验证结果 + +### 2.1 Node 版本 + +项目中未提供 `.nvmrc`,因此未能直接执行 `nvm use` 自动切换。 + +本机 `nvm` 已安装版本: + +- `v14.21.3` +- `v25.9.0` + +实际使用版本: + +- `nvm use 14.21.3` + +原因: + +- Vue CLI 4 + Vue 2 对较新的 Node 版本存在潜在兼容风险,优先使用更稳妥的 `v14.21.3` + +### 2.2 构建命令 + +已执行: + +- `cd ruoyi-ui && source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod` + +结果: + +- 构建成功 +- 输出 `DONE Build complete. The dist directory is ready to be deployed.` + +### 2.3 构建告警 + +存在 webpack 资源体积告警: + +- `asset size limit` +- `entrypoint size limit` + +说明: + +- 这些是现有项目静态资源体积告警,不影响本次缓存监控页适配结果 +- 本次未对该类历史体积问题做额外处理 + +## 3. 与计划差异 + +- 计划中预期通过 `nvm use` 自动切换版本,但项目没有 `.nvmrc`,因此改为显式执行 `nvm use 14.21.3` +- 除此之外,前端实施路径与计划一致 + +## 4. 当前结论 + +- 缓存监控首页已适配后端内存缓存统计结构 +- 缓存列表页与现有接口保持兼容 +- 前端生产构建已通过 diff --git a/doc/2026-04-15-移除Redis依赖改造为内存缓存后端实施记录.md b/doc/2026-04-15-移除Redis依赖改造为内存缓存后端实施记录.md new file mode 100644 index 0000000..92bafe6 --- /dev/null +++ b/doc/2026-04-15-移除Redis依赖改造为内存缓存后端实施记录.md @@ -0,0 +1,131 @@ +# 移除 Redis 依赖改造为内存缓存后端实施记录 + +## 1. 实际改动内容 + +### 1.1 新增内存缓存底座 + +新增文件: + +- `ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java` +- `ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java` +- `ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java` + +实现内容: + +- 使用 `ConcurrentHashMap` 存储缓存值 +- 支持 TTL、过期清理、按前缀查询 key、批量删除 +- 支持计数器递增能力,供限流使用 +- 提供命中、未命中、过期、写入、总键数统计快照 + +### 1.2 改造缓存门面 + +修改文件: + +- `ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisCache.java` + +改造内容: + +- 保留原 `RedisCache` 业务入口 +- 移除 `RedisTemplate` 依赖 +- 底层改为委托 `InMemoryCacheStore` +- 补充 `increment`、`getCacheStats`、`clear` 能力 + +### 1.3 改造限流与缓存监控 + +修改文件: + +- `ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java` +- `ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java` + +改造内容: + +- `RateLimiterAspect` 删除 Lua + Redis 依赖,改为内存计数 +- `CacheController` 改为返回内存缓存统计与缓存内容 +- 缓存清理接口改为操作本地缓存门面 + +### 1.4 删除 Redis 配置与依赖 + +修改文件: + +- `ruoyi-common/pom.xml` +- `ruoyi-framework/pom.xml` +- `ruoyi-admin/pom.xml` +- `ruoyi-admin/src/main/resources/application.yml` + +删除文件: + +- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java` +- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/FastJson2JsonRedisSerializer.java` + +处理结果: + +- 删除 `spring-boot-starter-data-redis` +- 删除 `commons-pool2` +- 删除 `spring.redis` 配置块 +- 删除 Redis 专属配置类 +- 为 `ruoyi-common`、`ruoyi-framework`、`ruoyi-admin` 增加 `spring-boot-starter-test` 测试依赖 + +### 1.5 新增后端测试 + +新增文件: + +- `ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java` +- `ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java` +- `ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java` +- `ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java` +- `ruoyi-admin/src/test/java/com/ruoyi/web/controller/monitor/CacheControllerTest.java` + +覆盖内容: + +- 内存缓存读写 / TTL / keys / 统计 +- 缓存门面能力 +- 限流计数 +- token 登录态缓存 +- 验证码删除 +- 防重提交毫秒级窗口 +- 缓存监控接口 + +## 2. 执行结果 + +### 2.1 验证命令 + +已执行: + +- `mvn -pl ruoyi-common -Dtest=InMemoryCacheStoreTest,RedisCacheTest test` +- `mvn -pl ruoyi-framework -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=RateLimiterAspectTest,TokenServiceLocalCacheTest test` +- `mvn -pl ruoyi-admin -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CacheControllerTest test` +- `mvn clean package -DskipTests` +- `mvn -pl ruoyi-common,ruoyi-framework,ruoyi-admin -am test` + +结果: + +- 上述命令全部执行成功 + +### 2.2 运行态冒烟 + +已执行: + +- `java -jar ruoyi-admin/target/ruoyi-admin.jar --server.port=18080` +- `curl http://127.0.0.1:18080/captchaImage` + +结果: + +- 应用可正常启动 +- 启动过程未出现 Redis 连接错误 +- `captchaImage` 返回:`{"msg":"操作成功","code":200,"captchaEnabled":false}` + +说明: + +- 默认端口 `8080` 被本机其它进程占用,因此改用临时端口 `18080` 做冒烟 +- 冒烟结束后已手动停止本次启动的 Java 进程 + +## 3. 与计划差异 + +- 计划中建议使用 `mvn -pl ruoyi-admin -am spring-boot:run` 启动应用,但本地 Maven 环境未解析到 `spring-boot` 插件前缀,因此改为直接运行已构建完成的 `ruoyi-admin.jar` +- 除上述启动方式调整外,其余后端实施路径与计划一致 + +## 4. 当前结论 + +- 后端已完成 Redis 依赖移除 +- Redis 相关能力已切换为单实例 JVM 内存实现 +- 构建、单元测试、运行态公开接口冒烟均已完成 diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index c52cea3..f99ed3f 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -55,13 +55,19 @@ ruoyi-quartz - - - com.ruoyi - ruoyi-generator - - - + + + com.ruoyi + ruoyi-generator + + + + org.springframework.boot + spring-boot-starter-test + test + + + @@ -93,4 +99,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 504c0fd..8f7935c 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,121 +1,148 @@ -package com.ruoyi.web.controller.monitor; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -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; - -/** - * 缓存监控 - * - * @author ruoyi - */ -@RestController -@RequestMapping("/monitor/cache") -public class CacheController -{ - @Autowired - private RedisTemplate redisTemplate; - - private final static List caches = new ArrayList(); - { - caches.add(new SysCache(CacheConstants.LOGIN_TOKEN_KEY, "用户信息")); - caches.add(new SysCache(CacheConstants.SYS_CONFIG_KEY, "配置信息")); - caches.add(new SysCache(CacheConstants.SYS_DICT_KEY, "数据字典")); - caches.add(new SysCache(CacheConstants.CAPTCHA_CODE_KEY, "验证码")); - caches.add(new SysCache(CacheConstants.REPEAT_SUBMIT_KEY, "防重提交")); - caches.add(new SysCache(CacheConstants.RATE_LIMIT_KEY, "限流处理")); - caches.add(new SysCache(CacheConstants.PWD_ERR_CNT_KEY, "密码错误次数")); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @GetMapping() - public AjaxResult getInfo() throws Exception - { - 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()); - - Map result = new HashMap<>(3); - result.put("info", info); - result.put("dbSize", dbSize); - - 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); - }); - result.put("commandStats", pieList); - return AjaxResult.success(result); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @GetMapping("/getNames") - public AjaxResult cache() - { - return AjaxResult.success(caches); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @GetMapping("/getKeys/{cacheName}") - public AjaxResult getCacheKeys(@PathVariable String cacheName) - { - Set cacheKeys = redisTemplate.keys(cacheName + "*"); - return AjaxResult.success(new TreeSet<>(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); - return AjaxResult.success(sysCache); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @DeleteMapping("/clearCacheName/{cacheName}") - public AjaxResult clearCacheName(@PathVariable String cacheName) - { - Collection cacheKeys = redisTemplate.keys(cacheName + "*"); - redisTemplate.delete(cacheKeys); - return AjaxResult.success(); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @DeleteMapping("/clearCacheKey/{cacheKey}") - public AjaxResult clearCacheKey(@PathVariable String cacheKey) - { - redisTemplate.delete(cacheKey); - return AjaxResult.success(); - } - - @PreAuthorize("@ss.hasPermi('monitor:cache:list')") - @DeleteMapping("/clearCacheAll") - public AjaxResult clearCacheAll() - { - Collection cacheKeys = redisTemplate.keys("*"); - redisTemplate.delete(cacheKeys); - return AjaxResult.success(); - } -} +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.Set; +import java.util.TreeSet; +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; + +/** + * 缓存监控 + * + * @author ruoyi + */ +@RestController +@RequestMapping("/monitor/cache") +public class CacheController +{ + private final RedisCache redisCache; + + private final static List caches = new ArrayList(); + { + caches.add(new SysCache(CacheConstants.LOGIN_TOKEN_KEY, "用户信息")); + caches.add(new SysCache(CacheConstants.SYS_CONFIG_KEY, "配置信息")); + caches.add(new SysCache(CacheConstants.SYS_DICT_KEY, "数据字典")); + caches.add(new SysCache(CacheConstants.CAPTCHA_CODE_KEY, "验证码")); + caches.add(new SysCache(CacheConstants.REPEAT_SUBMIT_KEY, "防重提交")); + caches.add(new SysCache(CacheConstants.RATE_LIMIT_KEY, "限流处理")); + caches.add(new SysCache(CacheConstants.PWD_ERR_CNT_KEY, "密码错误次数")); + } + + public CacheController(RedisCache redisCache) + { + this.redisCache = redisCache; + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @GetMapping() + public AjaxResult getInfo() + { + InMemoryCacheStats stats = redisCache.getCacheStats(); + Map info = new LinkedHashMap(); + info.put("cache_type", stats.getCacheType()); + info.put("cache_mode", stats.getMode()); + info.put("key_size", stats.getKeySize()); + info.put("hit_count", stats.getHitCount()); + info.put("miss_count", stats.getMissCount()); + info.put("expired_count", stats.getExpiredCount()); + info.put("write_count", stats.getWriteCount()); + + Map result = new HashMap(3); + result.put("info", info); + result.put("dbSize", stats.getKeySize()); + + List> pieList = new ArrayList>(); + pieList.add(statEntry("hit_count", stats.getHitCount())); + pieList.add(statEntry("miss_count", stats.getMissCount())); + pieList.add(statEntry("expired_count", stats.getExpiredCount())); + pieList.add(statEntry("write_count", stats.getWriteCount())); + result.put("commandStats", pieList); + return AjaxResult.success(result); + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @GetMapping("/getNames") + public AjaxResult cache() + { + return AjaxResult.success(caches); + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @GetMapping("/getKeys/{cacheName}") + public AjaxResult getCacheKeys(@PathVariable String cacheName) + { + 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) + { + Object cacheValue = redisCache.getCacheObject(cacheKey); + SysCache sysCache = new SysCache(cacheName, cacheKey, cacheValueToString(cacheValue)); + return AjaxResult.success(sysCache); + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @DeleteMapping("/clearCacheName/{cacheName}") + public AjaxResult clearCacheName(@PathVariable String cacheName) + { + Collection cacheKeys = redisCache.keys(cacheName + "*"); + redisCache.deleteObject(cacheKeys); + return AjaxResult.success(); + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @DeleteMapping("/clearCacheKey/{cacheKey}") + public AjaxResult clearCacheKey(@PathVariable String cacheKey) + { + redisCache.deleteObject(cacheKey); + return AjaxResult.success(); + } + + @PreAuthorize("@ss.hasPermi('monitor:cache:list')") + @DeleteMapping("/clearCacheAll") + public AjaxResult clearCacheAll() + { + 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) + { + return (String) cacheValue; + } + return JSON.toJSONString(cacheValue); + } +} diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 8e0063c..3d20776 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -60,36 +60,14 @@ spring: max-file-size: 10MB # 设置总上传的文件大小 max-request-size: 20MB - # 服务模块 - devtools: - restart: - # 热部署开关 - enabled: true - # 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 - -# token配置 -token: + # 服务模块 + devtools: + restart: + # 热部署开关 + enabled: true + +# token配置 +token: # 令牌自定义标识 header: Authorization # 令牌密钥 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..0e35601 --- /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 com.ruoyi.common.core.cache.InMemoryCacheStore; +import com.ruoyi.common.core.redis.RedisCache; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +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 f072d96..b0ae2ab 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -89,30 +89,24 @@ jaxb-api - - - org.springframework.boot - spring-boot-starter-data-redis - + + + nl.basjes.parse.useragent + yauaa + - - - org.apache.commons - commons-pool2 - - - - - nl.basjes.parse.useragent - yauaa - - - - - javax.servlet - javax.servlet-api - - - - - \ No newline at end of file + + + javax.servlet + javax.servlet-api + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java new file mode 100644 index 0000000..c048198 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java @@ -0,0 +1,29 @@ +package com.ruoyi.common.core.cache; + +class InMemoryCacheEntry +{ + private final Object value; + + private final Long expireAtMillis; + + InMemoryCacheEntry(Object value, Long expireAtMillis) + { + this.value = value; + this.expireAtMillis = expireAtMillis; + } + + Object getValue() + { + return value; + } + + Long getExpireAtMillis() + { + return expireAtMillis; + } + + boolean isExpired(long now) + { + return expireAtMillis != null && expireAtMillis.longValue() <= now; + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java new file mode 100644 index 0000000..41e4e43 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java @@ -0,0 +1,65 @@ +package com.ruoyi.common.core.cache; + +public class InMemoryCacheStats +{ + private final String cacheType; + + private final String mode; + + private final long keySize; + + private final long hitCount; + + private final long missCount; + + private final long expiredCount; + + private final long writeCount; + + public InMemoryCacheStats(String cacheType, String mode, long keySize, long hitCount, long missCount, + long expiredCount, long writeCount) + { + this.cacheType = cacheType; + this.mode = mode; + this.keySize = keySize; + this.hitCount = hitCount; + this.missCount = missCount; + this.expiredCount = expiredCount; + this.writeCount = writeCount; + } + + public String getCacheType() + { + return cacheType; + } + + public String getMode() + { + return mode; + } + + public long getKeySize() + { + return keySize; + } + + public long getHitCount() + { + return hitCount; + } + + public long getMissCount() + { + return missCount; + } + + public long getExpiredCount() + { + return expiredCount; + } + + public long getWriteCount() + { + return writeCount; + } +} 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 new file mode 100644 index 0000000..890da40 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java @@ -0,0 +1,290 @@ +package com.ruoyi.common.core.cache; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +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(); + + private final AtomicLong hitCount = new AtomicLong(); + + private final AtomicLong missCount = new AtomicLong(); + + private final AtomicLong expiredCount = new AtomicLong(); + + private final AtomicLong writeCount = new AtomicLong(); + + public void set(String key, Object value) + { + putEntry(key, new InMemoryCacheEntry(value, null)); + } + + public void set(String key, Object value, long timeout, TimeUnit unit) + { + Objects.requireNonNull(unit, "TimeUnit must not be null"); + long expireAtMillis = System.currentTimeMillis() + Math.max(0L, unit.toMillis(timeout)); + putEntry(key, new InMemoryCacheEntry(value, Long.valueOf(expireAtMillis))); + } + + @SuppressWarnings("unchecked") + public T get(String key) + { + InMemoryCacheEntry entry = readEntry(key); + return entry == null ? null : (T) entry.getValue(); + } + + public boolean hasKey(String key) + { + return readEntry(key) != null; + } + + public boolean delete(String key) + { + return entries.remove(key) != null; + } + + public boolean delete(Collection keys) + { + boolean deleted = false; + if (keys == null) + { + return false; + } + for (String key : keys) + { + deleted = delete(key) || deleted; + } + return deleted; + } + + public Set keys(String pattern) + { + purgeExpiredEntries(); + Set matchedKeys = new TreeSet(); + for (Map.Entry entry : entries.entrySet()) + { + if (matches(pattern, entry.getKey())) + { + matchedKeys.add(entry.getKey()); + } + } + 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.getValue(), Long.valueOf(expireAtMillis))) != null; + } + + public long getExpire(String key) + { + return getExpire(key, TimeUnit.SECONDS); + } + + public long getExpire(String key, TimeUnit unit) + { + Objects.requireNonNull(unit, "TimeUnit must not be null"); + InMemoryCacheEntry entry = readEntry(key); + if (entry == null) + { + return -2L; + } + if (entry.getExpireAtMillis() == null) + { + return -1L; + } + long remainingMillis = Math.max(0L, entry.getExpireAtMillis().longValue() - System.currentTimeMillis()); + long unitMillis = Math.max(1L, unit.toMillis(1L)); + return (remainingMillis + unitMillis - 1L) / 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.getValue()) + 1L; + Long expireAtMillis = missingOrExpired || currentEntry.getExpireAtMillis() == null + ? Long.valueOf(now + Math.max(0L, unit.toMillis(timeout))) + : currentEntry.getExpireAtMillis(); + if (missingOrExpired && currentEntry != null) + { + expiredCount.incrementAndGet(); + } + result.set(nextValue); + return new InMemoryCacheEntry(Long.valueOf(nextValue), expireAtMillis); + }); + writeCount.incrementAndGet(); + return result.get(); + } + + public void clear() + { + entries.clear(); + } + + public InMemoryCacheStats snapshot() + { + purgeExpiredEntries(); + return new InMemoryCacheStats("IN_MEMORY", "single-instance", entries.size(), hitCount.get(), missCount.get(), + expiredCount.get(), writeCount.get()); + } + + @SuppressWarnings("unchecked") + 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; + } + + @SuppressWarnings("unchecked") + 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)); + } + + @SuppressWarnings("unchecked") + public List getList(String key) + { + List value = get(key); + return value == null ? null : new ArrayList(value); + } + + public void putList(String key, List dataList) + { + set(key, new ArrayList(dataList)); + } + + private void putEntry(String key, InMemoryCacheEntry entry) + { + entries.put(key, entry); + writeCount.incrementAndGet(); + } + + private InMemoryCacheEntry readEntry(String key) + { + InMemoryCacheEntry entry = entries.get(key); + if (entry == null) + { + missCount.incrementAndGet(); + return null; + } + if (entry.isExpired(System.currentTimeMillis())) + { + removeExpiredEntry(key, entry); + missCount.incrementAndGet(); + return null; + } + hitCount.incrementAndGet(); + return entry; + } + + private void purgeExpiredEntries() + { + long now = System.currentTimeMillis(); + for (Map.Entry entry : entries.entrySet()) + { + if (entry.getValue().isExpired(now)) + { + removeExpiredEntry(entry.getKey(), entry.getValue()); + } + } + } + + private void removeExpiredEntry(String key, InMemoryCacheEntry expectedEntry) + { + if (entries.remove(key, expectedEntry)) + { + expiredCount.incrementAndGet(); + } + } + + private boolean matches(String pattern, String key) + { + if ("*".equals(pattern)) + { + return true; + } + if (pattern.endsWith("*")) + { + return key.startsWith(pattern.substring(0, pattern.length() - 1)); + } + return key.equals(pattern); + } + + private long toLong(Object value) + { + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + private void setWithOptionalTtl(String key, Object value, long ttlMillis) + { + if (ttlMillis > 0L) + { + set(key, value, ttlMillis, TimeUnit.MILLISECONDS); + } + else + { + 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 d0a6de4..38fe3ba 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,40 +1,42 @@ -package com.ruoyi.common.core.redis; - -import java.util.Collection; -import java.util.Iterator; -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; - -/** - * spring redis 工具类 - * - * @author ruoyi - **/ -@SuppressWarnings(value = { "unchecked", "rawtypes" }) -@Component -public class RedisCache -{ - @Autowired - public RedisTemplate redisTemplate; - - /** - * 缓存基本的对象,Integer、String、实体类等 +package com.ruoyi.common.core.redis; + +import com.ruoyi.common.core.cache.InMemoryCacheStats; +import com.ruoyi.common.core.cache.InMemoryCacheStore; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.springframework.stereotype.Component; + +/** + * 本地缓存门面,保留原有 RedisCache 业务入口。 + * + * @author ruoyi + **/ +@SuppressWarnings(value = { "unchecked", "rawtypes" }) +@Component +public class RedisCache +{ + 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); - } + */ + public void setCacheObject(final String key, final T value) + { + cacheStore.set(key, value); + } /** * 缓存基本的对象,Integer、String、实体类等 @@ -44,10 +46,10 @@ public class RedisCache * @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); - } + public void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) + { + cacheStore.set(key, value, timeout.longValue(), timeUnit); + } /** * 设置有效时间 @@ -69,10 +71,10 @@ public class RedisCache * @param unit 时间单位 * @return true=设置成功;false=设置失败 */ - public boolean expire(final String key, final long timeout, final TimeUnit unit) - { - return redisTemplate.expire(key, timeout, unit); - } + public boolean expire(final String key, final long timeout, final TimeUnit unit) + { + return cacheStore.expire(key, timeout, unit); + } /** * 获取有效时间 @@ -80,10 +82,10 @@ public class RedisCache * @param key Redis键 * @return 有效时间 */ - public long getExpire(final String key) - { - return redisTemplate.getExpire(key); - } + public long getExpire(final String key) + { + return cacheStore.getExpire(key); + } /** * 判断 key是否存在 @@ -91,10 +93,10 @@ public class RedisCache * @param key 键 * @return true 存在 false不存在 */ - public Boolean hasKey(String key) - { - return redisTemplate.hasKey(key); - } + public Boolean hasKey(String key) + { + return cacheStore.hasKey(key); + } /** * 获得缓存的基本对象。 @@ -102,21 +104,20 @@ public class RedisCache * @param key 缓存键值 * @return 缓存键值对应的数据 */ - public T getCacheObject(final String key) - { - ValueOperations operation = redisTemplate.opsForValue(); - return operation.get(key); - } + public T getCacheObject(final String key) + { + return cacheStore.get(key); + } /** * 删除单个对象 * * @param key */ - public boolean deleteObject(final String key) - { - return redisTemplate.delete(key); - } + public boolean deleteObject(final String key) + { + return cacheStore.delete(key); + } /** * 删除集合对象 @@ -124,10 +125,19 @@ public class RedisCache * @param collection 多个对象 * @return */ - public boolean deleteObject(final Collection collection) - { - return redisTemplate.delete(collection) > 0; - } + public boolean deleteObject(final Collection collection) + { + 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数据 @@ -136,11 +146,11 @@ public class RedisCache * @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; - } + public long setCacheList(final String key, final List dataList) + { + cacheStore.putList(key, dataList); + return dataList == null ? 0 : dataList.size(); + } /** * 获得缓存的list对象 @@ -148,10 +158,10 @@ public class RedisCache * @param key 缓存的键值 * @return 缓存键值对应的数据 */ - public List getCacheList(final String key) - { - return redisTemplate.opsForList().range(key, 0, -1); - } + public List getCacheList(final String key) + { + return cacheStore.getList(key); + } /** * 缓存Set @@ -160,16 +170,11 @@ public class RedisCache * @param dataSet 缓存的数据 * @return 缓存数据的对象 */ - public BoundSetOperations 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; - } + public long setCacheSet(final String key, final Set dataSet) + { + cacheStore.putSet(key, dataSet); + return dataSet == null ? 0 : dataSet.size(); + } /** * 获得缓存的set @@ -177,10 +182,10 @@ public class RedisCache * @param key * @return */ - public Set getCacheSet(final String key) - { - return redisTemplate.opsForSet().members(key); - } + public Set getCacheSet(final String key) + { + return cacheStore.getSet(key); + } /** * 缓存Map @@ -188,12 +193,13 @@ public class RedisCache * @param key * @param dataMap */ - public void setCacheMap(final String key, final Map dataMap) - { - if (dataMap != null) { - redisTemplate.opsForHash().putAll(key, dataMap); - } - } + public void setCacheMap(final String key, final Map dataMap) + { + if (dataMap != null) + { + cacheStore.putMap(key, dataMap); + } + } /** * 获得缓存的Map @@ -201,10 +207,10 @@ public class RedisCache * @param key * @return */ - public Map getCacheMap(final String key) - { - return redisTemplate.opsForHash().entries(key); - } + public Map getCacheMap(final String key) + { + return cacheStore.getMap(key); + } /** * 往Hash中存入数据 @@ -213,10 +219,10 @@ public class RedisCache * @param hKey Hash键 * @param value 值 */ - public void setCacheMapValue(final String key, final String hKey, final T value) - { - redisTemplate.opsForHash().put(key, hKey, value); - } + public void setCacheMapValue(final String key, final String hKey, final T value) + { + cacheStore.putMapValue(key, hKey, value); + } /** * 获取Hash中的数据 @@ -225,11 +231,11 @@ public class RedisCache * @param hKey Hash键 * @return Hash中的对象 */ - public T getCacheMapValue(final String key, final String hKey) - { - HashOperations opsForHash = redisTemplate.opsForHash(); - return opsForHash.get(key, hKey); - } + public T getCacheMapValue(final String key, final String hKey) + { + Map map = cacheStore.getMap(key); + return map == null ? null : map.get(hKey); + } /** * 获取多个Hash中的数据 @@ -238,10 +244,20 @@ public class RedisCache * @param hKeys Hash键集合 * @return Hash对象集合 */ - public List getMultiCacheMapValue(final String key, final Collection hKeys) - { - return redisTemplate.opsForHash().multiGet(key, hKeys); - } + public List getMultiCacheMapValue(final String key, final Collection 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中的某条数据 @@ -250,10 +266,10 @@ public class RedisCache * @param hKey Hash键 * @return 是否成功 */ - public boolean deleteCacheMapValue(final String key, final String hKey) - { - return redisTemplate.opsForHash().delete(key, hKey) > 0; - } + public boolean deleteCacheMapValue(final String key, final String hKey) + { + return cacheStore.deleteMapValue(key, hKey); + } /** * 获得缓存的基本对象列表 @@ -261,8 +277,23 @@ public class RedisCache * @param pattern 字符串前缀 * @return 对象列表 */ - public Collection keys(final String pattern) - { - return redisTemplate.keys(pattern); - } -} + public Collection keys(final String 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/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java b/ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java new file mode 100644 index 0000000..01fa231 --- /dev/null +++ b/ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java @@ -0,0 +1,58 @@ +package com.ruoyi.common.core.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class InMemoryCacheStoreTest +{ + @Test + void shouldExpireEntryAfterTtl() throws Exception + { + InMemoryCacheStore store = new InMemoryCacheStore(); + store.set("captcha_codes:1", "1234", 20L, TimeUnit.MILLISECONDS); + Thread.sleep(40L); + assertNull(store.get("captcha_codes:1")); + } + + @Test + void shouldReturnPrefixKeysInSortedOrder() + { + InMemoryCacheStore store = new InMemoryCacheStore(); + store.set("login_tokens:a", "A"); + store.set("login_tokens:b", "B"); + store.set("sys_dict:x", "X"); + + Set expected = new HashSet(); + expected.add("login_tokens:a"); + expected.add("login_tokens:b"); + assertEquals(expected, store.keys("login_tokens:*")); + } + + @Test + void shouldTrackHitsMissesWritesAndExpiredEntries() throws Exception + { + InMemoryCacheStore store = new InMemoryCacheStore(); + store.set("captcha_codes:2", "5678", 20L, TimeUnit.MILLISECONDS); + assertTrue(store.hasKey("captcha_codes:2")); + assertEquals("5678", store.get("captcha_codes:2")); + Thread.sleep(40L); + assertNull(store.get("captcha_codes:2")); + assertFalse(store.hasKey("captcha_codes:2")); + + InMemoryCacheStats stats = store.snapshot(); + assertEquals("IN_MEMORY", stats.getCacheType()); + assertEquals("single-instance", stats.getMode()); + assertEquals(0, stats.getKeySize()); + assertEquals(2L, stats.getHitCount()); + assertEquals(2L, stats.getMissCount()); + assertEquals(1L, stats.getExpiredCount()); + assertEquals(1L, stats.getWriteCount()); + } +} 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..b3c91f7 --- /dev/null +++ b/ruoyi-common/src/test/java/com/ruoyi/common/core/redis/RedisCacheTest.java @@ -0,0 +1,56 @@ +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.ArrayList; +import java.util.Collection; +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") > 0L); + assertEquals(2, cache.keys("login_tokens:*").size()); + + Collection keys = new ArrayList(); + keys.add("login_tokens:a"); + keys.add("login_tokens:b"); + assertTrue(cache.deleteObject(keys)); + assertFalse(cache.hasKey("login_tokens:a")); + } + + @Test + void shouldIncrementCounterWithinTtlWindow() throws Exception + { + RedisCache cache = new RedisCache(new InMemoryCacheStore()); + assertEquals(1L, cache.increment("rate_limit:test", 50L, TimeUnit.MILLISECONDS)); + assertEquals(2L, cache.increment("rate_limit:test", 50L, TimeUnit.MILLISECONDS)); + Thread.sleep(70L); + assertNull(cache.getCacheObject("rate_limit:test")); + assertEquals(1L, cache.increment("rate_limit:test", 50L, TimeUnit.MILLISECONDS)); + } +} diff --git a/ruoyi-framework/pom.xml b/ruoyi-framework/pom.xml index 29c245b..5ffcc3d 100644 --- a/ruoyi-framework/pom.xml +++ b/ruoyi-framework/pom.xml @@ -53,12 +53,18 @@ oshi-core - - - com.ruoyi - ruoyi-system - - - - - \ No newline at end of file + + + com.ruoyi + ruoyi-system + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + 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 a2015d7..18a093a 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,89 +1,75 @@ -package com.ruoyi.framework.aspectj; - -import java.lang.reflect.Method; -import java.util.Collections; -import java.util.List; -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.enums.LimitType; -import com.ruoyi.common.exception.ServiceException; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.ip.IpUtils; - -/** - * 限流处理 - * - * @author ruoyi - */ -@Aspect -@Component -public class RateLimiterAspect -{ - private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class); - - private RedisTemplate redisTemplate; - - private RedisScript limitScript; - - @Autowired - public void setRedisTemplate1(RedisTemplate redisTemplate) - { - this.redisTemplate = redisTemplate; - } - - @Autowired - public void setLimitScript(RedisScript limitScript) - { - this.limitScript = limitScript; - } - - @Before("@annotation(rateLimiter)") - public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable - { - int time = rateLimiter.time(); - 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) - { - throw new ServiceException("访问过于频繁,请稍候再试"); - } - log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), combineKey); - } - catch (ServiceException e) - { - throw e; - } - catch (Exception e) - { - throw new RuntimeException("服务器限流异常,请稍候再试"); - } - } - - public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) - { - StringBuffer stringBuffer = new StringBuffer(rateLimiter.key()); - if (rateLimiter.limitType() == LimitType.IP) - { - stringBuffer.append(IpUtils.getIpAddr()).append("-"); - } - MethodSignature signature = (MethodSignature) point.getSignature(); - Method method = signature.getMethod(); - Class targetClass = method.getDeclaringClass(); - stringBuffer.append(targetClass.getName()).append("-").append(method.getName()); - return stringBuffer.toString(); - } -} +package com.ruoyi.framework.aspectj; + +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.ip.IpUtils; +import java.lang.reflect.Method; +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.stereotype.Component; + +/** + * 限流处理 + * + * @author ruoyi + */ +@Aspect +@Component +public class RateLimiterAspect +{ + private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class); + + private final RedisCache redisCache; + + public RateLimiterAspect(RedisCache redisCache) + { + this.redisCache = redisCache; + } + + @Before("@annotation(rateLimiter)") + public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable + { + int time = rateLimiter.time(); + int count = rateLimiter.count(); + + String combineKey = getCombineKey(rateLimiter, point); + try + { + long number = redisCache.increment(combineKey, time, TimeUnit.SECONDS); + if (number > count) + { + throw new ServiceException("访问过于频繁,请稍候再试"); + } + log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number, combineKey); + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + throw new RuntimeException("服务器限流异常,请稍候再试"); + } + } + + public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) + { + StringBuffer stringBuffer = new StringBuffer(rateLimiter.key()); + if (rateLimiter.limitType() == LimitType.IP) + { + stringBuffer.append(IpUtils.getIpAddr()).append("-"); + } + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + Class targetClass = method.getDeclaringClass(); + stringBuffer.append(targetClass.getName()).append("-").append(method.getName()); + return stringBuffer.toString(); + } +} 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 bd369b4..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 b188ac2..0000000 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java +++ /dev/null @@ -1,69 +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 - */ -@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..6034462 --- /dev/null +++ b/ruoyi-framework/src/test/java/com/ruoyi/framework/aspectj/RateLimiterAspectTest.java @@ -0,0 +1,50 @@ +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 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; +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; + +class RateLimiterAspectTest +{ + @Test + void shouldRejectThirdRequestWithinWindow() throws Throwable + { + RateLimiterAspect aspect = new RateLimiterAspect(new RedisCache(new InMemoryCacheStore())); + Method method = TestRateLimitTarget.class.getDeclaredMethod("limited"); + JoinPoint joinPoint = mockJoinPoint(method); + RateLimiter rateLimiter = method.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..959c245 --- /dev/null +++ b/ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/TokenServiceLocalCacheTest.java @@ -0,0 +1,116 @@ +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 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; +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; + +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()) > 0L); + } + + @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(70L); + 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() + { + } + } +} diff --git a/ruoyi-ui/src/views/monitor/cache/index.vue b/ruoyi-ui/src/views/monitor/cache/index.vue index f8b6648..531ebb5 100644 --- a/ruoyi-ui/src/views/monitor/cache/index.vue +++ b/ruoyi-ui/src/views/monitor/cache/index.vue @@ -8,34 +8,34 @@ - - + + - - - - - + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + +
Redis版本
{{ cache.info.redis_version }}
缓存类型
{{ cacheTypeText }}
运行模式
{{ cache.info.redis_mode == "standalone" ? "单机" : "集群" }}
端口
{{ cache.info.tcp_port }}
客户端数
{{ cache.info.connected_clients }}
{{ cacheModeText }}
总键数
{{ displayDbSize }}
写入次数
{{ cache.info.write_count }}
运行时间(天)
{{ cache.info.uptime_in_days }}
使用内存
{{ cache.info.used_memory_human }}
使用CPU
{{ parseFloat(cache.info.used_cpu_user_children).toFixed(2) }}
内存配置
{{ cache.info.maxmemory_human }}
命中次数
{{ cache.info.hit_count }}
未命中次数
{{ cache.info.miss_count }}
过期清理次数
{{ cache.info.expired_count }}
命中率
{{ hitRateText }}
AOF是否开启
{{ cache.info.aof_enabled == "0" ? "否" : "是" }}
RDB是否成功
{{ cache.info.rdb_last_bgsave_status }}
Key数量
{{ cache.dbSize }}
网络入口/出口
{{ cache.info.instantaneous_input_kbps }}kps/{{cache.info.instantaneous_output_kbps}}kps
统计说明
进程内累计统计
监控范围
当前应用实例
图表数据项
{{ commandStats.length }}
监控采样时间
{{ sampleTime }}
@@ -45,7 +45,7 @@ -
命令统计
+
缓存统计
@@ -54,7 +54,7 @@ -
内存信息
+
命中概览
@@ -72,76 +72,201 @@ export default { name: "Cache", data() { return { - // 统计命令信息 commandstats: null, - // 使用内存 usedmemory: null, - // cache信息 - cache: [] + cache: { + info: { + cache_type: "IN_MEMORY", + cache_mode: "single-instance", + key_size: 0, + hit_count: 0, + miss_count: 0, + expired_count: 0, + write_count: 0 + }, + dbSize: 0, + commandStats: [] + }, + sampleTime: "-", + resizeHandler: null } }, created() { - this.getList() this.openLoading() + this.getList() + }, + beforeDestroy() { + if (this.resizeHandler) { + window.removeEventListener("resize", this.resizeHandler) + } + if (this.commandstats) { + this.commandstats.dispose() + } + if (this.usedmemory) { + this.usedmemory.dispose() + } + }, + computed: { + cacheTypeText() { + return this.formatCacheType(this.cache.info.cache_type) + }, + cacheModeText() { + return this.formatCacheMode(this.cache.info.cache_mode) + }, + displayDbSize() { + return this.toNumber(this.cache.dbSize || this.cache.info.key_size) + }, + hitRateText() { + const hitCount = this.toNumber(this.cache.info.hit_count) + const missCount = this.toNumber(this.cache.info.miss_count) + const total = hitCount + missCount + if (!total) { + return "0.00%" + } + return ((hitCount / total) * 100).toFixed(2) + "%" + }, + commandStats() { + return this.normalizeCommandStats(this.cache.commandStats) + } }, methods: { - /** 查缓存询信息 */ getList() { getCache().then((response) => { - this.cache = response.data + this.cache = this.normalizeCacheData(response.data) + this.sampleTime = this.formatSampleTime(new Date()) this.$modal.closeLoading() - - this.commandstats = echarts.init(this.$refs.commandstats, "macarons") - this.commandstats.setOption({ - tooltip: { - trigger: "item", - formatter: "{a}
{b} : {c} ({d}%)", - }, - series: [ - { - name: "命令", - type: "pie", - roseType: "radius", - radius: [15, 95], - center: ["50%", "38%"], - data: response.data.commandStats, - animationEasing: "cubicInOut", - animationDuration: 1000, - } - ] - }) - this.usedmemory = echarts.init(this.$refs.usedmemory, "macarons") - this.usedmemory.setOption({ - tooltip: { - formatter: "{b}
{a} : " + this.cache.info.used_memory_human, - }, - series: [ - { - name: "峰值", - type: "gauge", - min: 0, - max: 1000, - detail: { - formatter: this.cache.info.used_memory_human, - }, - data: [ - { - value: parseFloat(this.cache.info.used_memory_human), - name: "内存消耗", - } - ] - } - ] - }) - window.addEventListener("resize", () => { - this.commandstats.resize() - this.usedmemory.resize() + this.$nextTick(() => { + this.renderCharts() }) + }).catch(() => { + this.cache = this.normalizeCacheData() + this.sampleTime = "-" + this.$modal.closeLoading() }) }, - // 打开加载层 openLoading() { this.$modal.loading("正在加载缓存监控数据,请稍候!") + }, + normalizeCacheData(data) { + const source = data || {} + const info = source.info || {} + return { + info: { + cache_type: info.cache_type || "IN_MEMORY", + cache_mode: info.cache_mode || "single-instance", + key_size: this.toNumber(info.key_size), + hit_count: this.toNumber(info.hit_count), + miss_count: this.toNumber(info.miss_count), + expired_count: this.toNumber(info.expired_count), + write_count: this.toNumber(info.write_count) + }, + dbSize: this.toNumber(source.dbSize || info.key_size), + commandStats: source.commandStats || [] + } + }, + normalizeCommandStats(commandStats) { + const stats = Array.isArray(commandStats) ? commandStats : [] + return stats.map((item) => ({ + name: this.statLabel(item.name), + value: this.toNumber(item.value) + })) + }, + statLabel(name) { + const labelMap = { + hit_count: "命中次数", + miss_count: "未命中次数", + expired_count: "过期清理次数", + write_count: "写入次数" + } + return labelMap[name] || name || "未命名统计" + }, + formatCacheType(type) { + const typeMap = { + IN_MEMORY: "进程内缓存" + } + return typeMap[type] || type || "-" + }, + formatCacheMode(mode) { + const modeMap = { + "single-instance": "单实例" + } + return modeMap[mode] || mode || "-" + }, + formatSampleTime(date) { + const current = date || new Date() + const pad = (value) => String(value).padStart(2, "0") + return `${current.getFullYear()}-${pad(current.getMonth() + 1)}-${pad(current.getDate())} ${pad(current.getHours())}:${pad(current.getMinutes())}:${pad(current.getSeconds())}` + }, + toNumber(value) { + const parsedValue = Number(value) + return Number.isFinite(parsedValue) ? parsedValue : 0 + }, + renderCharts() { + if (!this.commandstats) { + this.commandstats = echarts.init(this.$refs.commandstats, "macarons") + } + if (!this.usedmemory) { + this.usedmemory = echarts.init(this.$refs.usedmemory, "macarons") + } + + this.commandstats.setOption({ + tooltip: { + trigger: "item", + formatter: "{a}
{b} : {c} ({d}%)" + }, + series: [ + { + name: "缓存统计", + type: "pie", + roseType: "radius", + radius: [15, 95], + center: ["50%", "38%"], + data: this.commandStats, + animationEasing: "cubicInOut", + animationDuration: 1000 + } + ] + }) + + this.usedmemory.setOption({ + tooltip: { + trigger: "axis", + axisPointer: { + type: "shadow" + } + }, + xAxis: { + type: "category", + data: this.commandStats.map((item) => item.name), + axisTick: { + alignWithLabel: true + } + }, + yAxis: { + type: "value", + minInterval: 1 + }, + series: [ + { + name: "统计次数", + type: "bar", + barMaxWidth: 48, + data: this.commandStats.map((item) => item.value) + } + ] + }) + + if (!this.resizeHandler) { + this.resizeHandler = () => { + if (this.commandstats) { + this.commandstats.resize() + } + if (this.usedmemory) { + this.usedmemory.resize() + } + } + window.addEventListener("resize", this.resizeHandler) + } } } } diff --git a/ruoyi-ui/src/views/monitor/cache/list.vue b/ruoyi-ui/src/views/monitor/cache/list.vue index 5aeab1f..ad4a85e 100644 --- a/ruoyi-ui/src/views/monitor/cache/list.vue +++ b/ruoyi-ui/src/views/monitor/cache/list.vue @@ -230,12 +230,16 @@ export default { this.cacheForm = response.data }) }, - /** 清理全部缓存 */ - handleClearCacheAll() { - clearCacheAll().then(response => { - this.$modal.msgSuccess("清理全部缓存成功") - }) - } - } -} + /** 清理全部缓存 */ + handleClearCacheAll() { + clearCacheAll().then(response => { + this.cacheKeys = [] + this.cacheForm = {} + this.nowCacheName = "" + this.getCacheNames() + this.$modal.msgSuccess("清理全部缓存成功") + }) + } + } +}