mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
缓存框架
This commit is contained in:
+204
@@ -455,6 +455,167 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/clear": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "清空缓存",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "清理成功",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/config": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新缓存配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "缓存配置请求体",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cache.updateCacheConfigRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "更新成功",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/status": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取缓存状态",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/diskcache.Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-export": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -4350,6 +4511,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"cache.updateCacheConfigRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"max_size_mb",
|
||||
"ttl_minutes"
|
||||
],
|
||||
"properties": {
|
||||
"lru_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_size_mb": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"cap.ChallengeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4484,6 +4665,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"diskcache.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"keys_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lru_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_size_mb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logger.LogEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -448,6 +448,167 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/clear": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "清空缓存",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "清理成功",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/config": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新缓存配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "缓存配置请求体",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cache.updateCacheConfigRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "更新成功",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cache/status": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取缓存状态",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/diskcache.Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-export": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -4343,6 +4504,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cache.updateCacheConfigRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"max_size_mb",
|
||||
"ttl_minutes"
|
||||
],
|
||||
"properties": {
|
||||
"lru_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_size_mb": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"cap.ChallengeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4477,6 +4658,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diskcache.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"keys_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lru_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_size_mb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logger.LogEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -26,6 +26,20 @@ definitions:
|
||||
is_active:
|
||||
type: boolean
|
||||
type: object
|
||||
cache.updateCacheConfigRequest:
|
||||
properties:
|
||||
lru_enabled:
|
||||
type: boolean
|
||||
max_size_mb:
|
||||
minimum: 1
|
||||
type: integer
|
||||
ttl_minutes:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- max_size_mb
|
||||
- ttl_minutes
|
||||
type: object
|
||||
cap.ChallengeResponse:
|
||||
properties:
|
||||
challenge:
|
||||
@@ -114,6 +128,21 @@ definitions:
|
||||
description: '"select" 或 "exec"'
|
||||
type: string
|
||||
type: object
|
||||
diskcache.Status:
|
||||
properties:
|
||||
base_path:
|
||||
type: string
|
||||
keys_count:
|
||||
type: integer
|
||||
lru_enabled:
|
||||
type: boolean
|
||||
max_size_mb:
|
||||
type: integer
|
||||
total_size:
|
||||
type: integer
|
||||
ttl_minutes:
|
||||
type: integer
|
||||
type: object
|
||||
logger.LogEntry:
|
||||
properties:
|
||||
data:
|
||||
@@ -1277,6 +1306,105 @@ paths:
|
||||
summary: 切换认证源启用状态
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/cache/clear:
|
||||
post:
|
||||
description: 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 清理成功
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 服务内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 清空缓存
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/cache/config:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新
|
||||
parameters:
|
||||
- description: 缓存配置请求体
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/cache.updateCacheConfigRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 更新成功
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 服务内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 更新缓存配置
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/cache/status:
|
||||
get:
|
||||
description: 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 获取成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/diskcache.Status'
|
||||
type: object
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取缓存状态
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/db-export:
|
||||
get:
|
||||
description: SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限
|
||||
|
||||
@@ -26,7 +26,18 @@ import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import type {DBOverview, ExecuteSQLResponse, TableDataResponse} from "@/lib/services"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus, DBOverview, ExecuteSQLResponse, TableDataResponse} from "@/lib/services"
|
||||
import services, {AdminService} from "@/lib/services"
|
||||
|
||||
/**
|
||||
@@ -77,6 +88,16 @@ export function DatabaseManager() {
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
|
||||
const [sqlError, setSqlError] = useState<string | null>(null)
|
||||
|
||||
// 缓存管理状态
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null)
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(false)
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false)
|
||||
const [clearingCache, setClearingCache] = useState<boolean>(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState<string>("100")
|
||||
const [ttlMinutes, setTtlMinutes] = useState<string>("60")
|
||||
const [lruEnabled, setLruEnabled] = useState<boolean>(true)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false)
|
||||
|
||||
// 拖拽与主题状态
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240)
|
||||
@@ -204,11 +225,87 @@ export function DatabaseManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 格式化字节大小
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
// 7. 获取磁盘缓存状态
|
||||
const fetchCacheStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingCache(true)
|
||||
try {
|
||||
const data = await AdminService.getCacheStatus()
|
||||
setCacheStatus(data)
|
||||
setMaxSizeMB(data.max_size_mb.toString())
|
||||
setTtlMinutes(data.ttl_minutes.toString())
|
||||
setLruEnabled(data.lru_enabled)
|
||||
} catch (err) {
|
||||
toast.error("获取磁盘缓存状态失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 8. 保存磁盘缓存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
}
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
await AdminService.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
try {
|
||||
await AdminService.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
fetchCacheStatus()
|
||||
}, [fetchOverview, fetchTables, fetchCacheStatus])
|
||||
|
||||
// 选择数据表或页码变化时,拉取对应的数据
|
||||
useEffect(() => {
|
||||
@@ -665,6 +762,182 @@ export function DatabaseManager() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2.5 缓存管理 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="size-4 text-primary animate-pulse" />
|
||||
<CardTitle className="text-sm font-semibold">缓存管理</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start md:self-auto"
|
||||
onClick={() => fetchCacheStatus(true)}
|
||||
disabled={loadingCache}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingCache ? "animate-spin" : ""}`} />
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingCache && !cacheStatus ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : cacheStatus ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* 左边:状态区 (2/5 cols) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">运行状态</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 已占空间 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">已用空间</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatBytes(cacheStatus.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Key数量 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">缓存键数量</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatNumber(cacheStatus.keys_count)} <span className="text-xs text-muted-foreground font-normal">个文件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储路径 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1.5">缓存基准目录</p>
|
||||
<code className="text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate" title={cacheStatus.base_path}>
|
||||
{cacheStatus.base_path}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-medium w-full"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 右边:配置区 (3/5 cols) */}
|
||||
<div className="lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">策略配置</h4>
|
||||
<form onSubmit={handleSaveConfig} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="maxSizeMB" className="text-xs font-medium">最大容量限制 (MB)</Label>
|
||||
<Input
|
||||
id="maxSizeMB"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<Input
|
||||
id="ttlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
value={ttlMinutes}
|
||||
onChange={(e) => setTtlMinutes(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 60,0 表示永不过期"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20">
|
||||
<div className="space-y-1 pr-4">
|
||||
<Label htmlFor="lruEnabled" className="text-xs font-semibold block cursor-pointer">
|
||||
启用 LRU 淘汰机制
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
>
|
||||
{savingConfig ? "正在保存..." : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<HardDrive className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未加载到缓存状态信息</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2.6 清除缓存确认对话框 */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="size-4" />
|
||||
确认清空所有缓存?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground pt-1">
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 3. 底部功能区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdminUser,
|
||||
AuthSource,
|
||||
AuthSourceRequest,
|
||||
CacheConfig,
|
||||
CacheStatus,
|
||||
CreateScheduleRequest,
|
||||
CreateSystemConfigRequest,
|
||||
CreateTemplateRequest,
|
||||
@@ -28,7 +30,7 @@ import type {
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
export type { AdminUser } from './types';
|
||||
export type { AdminUser, CacheStatus, CacheConfig } from './types';
|
||||
|
||||
/**
|
||||
* 管理员服务
|
||||
@@ -133,7 +135,7 @@ export class AdminService extends BaseService {
|
||||
}): Promise<{ success: boolean; log: string; error: string }> {
|
||||
return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有已存在的文件业务类型及默认内置类型列表
|
||||
* @returns 业务类型列表
|
||||
@@ -506,4 +508,27 @@ export class AdminService extends BaseService {
|
||||
static async deleteTemplate(key: string): Promise<void> {
|
||||
return this.delete<void>(`/templates/${ key }`);
|
||||
}
|
||||
|
||||
// ==================== 磁盘缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 获取磁盘缓存状态与当前统计数据
|
||||
*/
|
||||
static async getCacheStatus(): Promise<CacheStatus> {
|
||||
return this.get<CacheStatus>('/cache/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新磁盘缓存策略配置
|
||||
*/
|
||||
static async updateCacheConfig(config: CacheConfig): Promise<void> {
|
||||
return this.post<void>('/cache/config', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键清空所有磁盘缓存数据
|
||||
*/
|
||||
static async clearCache(): Promise<void> {
|
||||
return this.post<void>('/cache/clear');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,6 @@ export type {
|
||||
Schedule,
|
||||
CreateScheduleRequest,
|
||||
UpdateScheduleRequest,
|
||||
CacheStatus,
|
||||
CacheConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -438,3 +438,36 @@ export interface DatabaseInfo {
|
||||
/** 数据库版本字符串 */
|
||||
version: string;
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 缓存状态信息
|
||||
*/
|
||||
export interface CacheStatus {
|
||||
/** 缓存文件总大小(字节) */
|
||||
total_size: number;
|
||||
/** 缓存 Key 数量 */
|
||||
keys_count: number;
|
||||
/** 最大容量限制 (MB) */
|
||||
max_size_mb: number;
|
||||
/** 过期时间 (分钟) */
|
||||
ttl_minutes: number;
|
||||
/** 是否启用 LRU 淘汰 */
|
||||
lru_enabled: boolean;
|
||||
/** 缓存目录基准路径 */
|
||||
base_path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存配置请求参数
|
||||
*/
|
||||
export interface CacheConfig {
|
||||
/** 最大容量限制 (MB) */
|
||||
max_size_mb: number;
|
||||
/** 过期时间 (分钟) */
|
||||
ttl_minutes: number;
|
||||
/** 是否启用 LRU 淘汰 */
|
||||
lru_enabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ export type {
|
||||
Schedule,
|
||||
CreateScheduleRequest,
|
||||
UpdateScheduleRequest,
|
||||
CacheStatus,
|
||||
CacheConfig,
|
||||
} from './admin';
|
||||
|
||||
// 用户服务
|
||||
|
||||
@@ -105,6 +105,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gomodule/redigo v1.9.3 // indirect
|
||||
github.com/google/btree v1.0.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
@@ -127,6 +128,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
|
||||
@@ -157,6 +157,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
||||
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -231,6 +233,8 @@ github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/En
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
|
||||
Vendored
+135
@@ -0,0 +1,135 @@
|
||||
// Package cache provides HTTP handlers for managing disk cache.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/diskcache"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type updateCacheConfigRequest struct {
|
||||
MaxSizeMB int64 `json:"max_size_mb" binding:"required,min=1"`
|
||||
TTLMinutes int64 `json:"ttl_minutes" binding:"required,min=0"`
|
||||
LRUEnabled bool `json:"lru_enabled"`
|
||||
}
|
||||
|
||||
// GetCacheStatus 获取磁盘缓存状态与当前统计数据
|
||||
// @Summary 获取缓存状态
|
||||
// @Description 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=diskcache.Status} "获取成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/cache/status [get]
|
||||
func GetCacheStatus(c *gin.Context) {
|
||||
status := diskcache.GetGlobalCache().Status()
|
||||
c.JSON(http.StatusOK, util.OK(status))
|
||||
}
|
||||
|
||||
// UpdateCacheConfig 更新磁盘缓存策略配置
|
||||
// @Summary 更新缓存配置
|
||||
// @Description 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body cache.updateCacheConfigRequest true "缓存配置请求体"
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny "更新成功"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||
// @Router /api/v1/admin/cache/config [post]
|
||||
func UpdateCacheConfig(c *gin.Context) {
|
||||
var req updateCacheConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Update Max Size
|
||||
if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Update Default TTL
|
||||
if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Update LRU Enabled
|
||||
if err := saveOrUpdateConfig(ctx, model.ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger hot reloading in global cache
|
||||
diskcache.GetGlobalCache().ReloadConfig(ctx)
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// ClearCache 一键清空所有磁盘缓存数据
|
||||
// @Summary 清空缓存
|
||||
// @Description 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny "清理成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||
// @Router /api/v1/admin/cache/clear [post]
|
||||
func ClearCache(c *gin.Context) {
|
||||
if err := diskcache.GetGlobalCache().Clear(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
func saveOrUpdateConfig(ctx context.Context, key string, value string) error {
|
||||
var sc model.SystemConfig
|
||||
err := db.DB(ctx).Where("key = ?", key).First(&sc).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
sc = model.SystemConfig{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Type: "system",
|
||||
Visibility: 0,
|
||||
}
|
||||
if err := db.DB(ctx).Create(&sc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
sc.Value = value
|
||||
if err := db.DB(ctx).Save(&sc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Sync config value in Redis cache
|
||||
if db.Redis != nil {
|
||||
_ = db.HSetJSON(ctx, model.SystemConfigRedisHashKey, key, &sc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -137,9 +137,9 @@ func TestListSystemConfigs(t *testing.T) {
|
||||
var configs []model.SystemConfig
|
||||
_ = json.Unmarshal(dataBytes, &configs)
|
||||
|
||||
// Defaults seed 24 configurations
|
||||
if len(configs) != 24 {
|
||||
t.Errorf("expected 24 default configs, got %d", len(configs))
|
||||
// Defaults seed 27 configurations
|
||||
if len(configs) != 27 {
|
||||
t.Errorf("expected 27 default configs, got %d", len(configs))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('disk_cache_max_size_mb', '100', 'system', 0, '磁盘缓存最大空间大小 (MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('disk_cache_ttl_minutes', '60', 'system', 0, '磁盘缓存默认有效期 (分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('disk_cache_max_size_mb', '100', 'system', 0, '磁盘缓存最大空间大小 (MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('disk_cache_ttl_minutes', '60', 'system', 0, '磁盘缓存默认有效期 (分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
|
||||
@@ -38,8 +38,8 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||
if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil {
|
||||
t.Fatalf("Migrate() count w_system_configs error = %v", err)
|
||||
}
|
||||
if systemConfigCount != 24 {
|
||||
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 24)
|
||||
if systemConfigCount != 27 {
|
||||
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 27)
|
||||
}
|
||||
|
||||
var adminCount int64
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
// Package diskcache implements a platform-level disk-backed cache with size limit, TTL, and LRU eviction.
|
||||
package diskcache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/peterbourgon/diskv/v3"
|
||||
)
|
||||
|
||||
// ErrCacheMiss represents a cache miss.
|
||||
var ErrCacheMiss = errors.New("cache miss")
|
||||
|
||||
// Constants for disk cache configuration and sizing
|
||||
const (
|
||||
defaultCacheDir = "uploads/diskcache"
|
||||
headerSize = 8 // 8 bytes metadata prefix for expiration UnixNano timestamp
|
||||
defaultMaxSizeMB = 100
|
||||
defaultTTLMinutes = 60
|
||||
defaultCleanupInterval = 10
|
||||
)
|
||||
|
||||
var (
|
||||
globalCache *DiskCache
|
||||
globalCacheOnce sync.Once
|
||||
)
|
||||
|
||||
// Status represents the runtime cache statistics.
|
||||
type Status struct {
|
||||
TotalSize int64 `json:"total_size"`
|
||||
KeysCount int `json:"keys_count"`
|
||||
MaxSizeMB int64 `json:"max_size_mb"`
|
||||
TTLMinutes int64 `json:"ttl_minutes"`
|
||||
LRUEnabled bool `json:"lru_enabled"`
|
||||
BasePath string `json:"base_path"`
|
||||
}
|
||||
|
||||
// DiskCache implements the disk-backed cache with size limits, TTL, and LRU eviction.
|
||||
type DiskCache struct {
|
||||
mu sync.RWMutex
|
||||
d *diskv.Diskv
|
||||
basePath string
|
||||
maxSize int64 // in bytes
|
||||
defaultTTL time.Duration
|
||||
lruEnabled bool
|
||||
|
||||
// LRU and Size tracking
|
||||
currentSize int64
|
||||
items map[string]*list.Element
|
||||
evictList *list.List
|
||||
}
|
||||
|
||||
type cacheItem struct {
|
||||
key string
|
||||
size int64
|
||||
expiredAt time.Time
|
||||
}
|
||||
|
||||
// GetGlobalCache returns the global singleton DiskCache instance.
|
||||
func GetGlobalCache() *DiskCache {
|
||||
globalCacheOnce.Do(func() {
|
||||
globalCache = New(defaultCacheDir)
|
||||
// Load initial configs from database
|
||||
globalCache.ReloadConfig(context.Background())
|
||||
// Start background routine to clean expired items every 10 minutes
|
||||
go globalCache.startCleanupWorker(defaultCleanupInterval * time.Minute)
|
||||
})
|
||||
return globalCache
|
||||
}
|
||||
|
||||
// New creates a new DiskCache instance.
|
||||
func New(basePath string) *DiskCache {
|
||||
d := diskv.New(diskv.Options{
|
||||
BasePath: basePath,
|
||||
Transform: func(_ string) []string { return []string{} }, // flat structure for easy walk
|
||||
CacheSizeMax: 1024 * 1024, // 1MB in-memory cache size for diskv itself
|
||||
})
|
||||
|
||||
c := &DiskCache{
|
||||
d: d,
|
||||
basePath: basePath,
|
||||
maxSize: defaultMaxSizeMB * 1024 * 1024, // 100MB default
|
||||
defaultTTL: defaultTTLMinutes * time.Minute, // 60 minutes default
|
||||
lruEnabled: true,
|
||||
items: make(map[string]*list.Element),
|
||||
evictList: list.New(),
|
||||
}
|
||||
|
||||
// Scan directory on startup to rebuild LRU and size tracking
|
||||
_ = c.loadTracker()
|
||||
return c
|
||||
}
|
||||
|
||||
// Set stores a key-value pair in the cache with a specific TTL.
|
||||
// If ttl <= 0, the default cache TTL is used.
|
||||
func (c *DiskCache) Set(key string, value []byte, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if ttl <= 0 {
|
||||
ttl = c.defaultTTL
|
||||
}
|
||||
|
||||
var expiredAt time.Time
|
||||
if ttl > 0 {
|
||||
expiredAt = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
// Prepare data layout: 8 bytes expiration timestamp + raw payload
|
||||
buf := make([]byte, headerSize+len(value))
|
||||
var expNano int64
|
||||
if !expiredAt.IsZero() {
|
||||
expNano = expiredAt.UnixNano()
|
||||
}
|
||||
binary.BigEndian.PutUint64(buf[0:headerSize], uint64(expNano))
|
||||
copy(buf[headerSize:], value)
|
||||
|
||||
// Write to diskv
|
||||
if err := c.d.Write(key, buf); err != nil {
|
||||
return fmt.Errorf("failed to write key to disk: %w", err)
|
||||
}
|
||||
|
||||
// Get file size on disk (approximate)
|
||||
size := int64(len(buf))
|
||||
|
||||
// Update memory tracker
|
||||
if elem, ok := c.items[key]; ok {
|
||||
item := elem.Value.(*cacheItem)
|
||||
c.currentSize += size - item.size
|
||||
item.size = size
|
||||
item.expiredAt = expiredAt
|
||||
c.evictList.MoveToFront(elem)
|
||||
} else {
|
||||
item := &cacheItem{
|
||||
key: key,
|
||||
size: size,
|
||||
expiredAt: expiredAt,
|
||||
}
|
||||
elem := c.evictList.PushFront(item)
|
||||
c.items[key] = elem
|
||||
c.currentSize += size
|
||||
}
|
||||
|
||||
// Evict items if size limit exceeded and LRU is enabled
|
||||
c.evict()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a key's value from the cache.
|
||||
func (c *DiskCache) Get(key string) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
elem, ok := c.items[key]
|
||||
if !ok {
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
item := elem.Value.(*cacheItem)
|
||||
|
||||
// Check expiration
|
||||
if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) {
|
||||
// Lazily delete expired item
|
||||
_ = c.deleteUnlocked(key)
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
// Read from diskv
|
||||
data, err := c.d.Read(key)
|
||||
if err != nil {
|
||||
// Key exists in memory but not on disk, sync state
|
||||
_ = c.deleteUnlocked(key)
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
if len(data) < headerSize {
|
||||
_ = c.deleteUnlocked(key)
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
// Update LRU access order
|
||||
c.evictList.MoveToFront(elem)
|
||||
|
||||
// Slice off the metadata header
|
||||
return data[headerSize:], nil
|
||||
}
|
||||
|
||||
// Delete removes a key-value pair from the cache.
|
||||
func (c *DiskCache) Delete(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.deleteUnlocked(key)
|
||||
}
|
||||
|
||||
func (c *DiskCache) deleteUnlocked(key string) error {
|
||||
if elem, ok := c.items[key]; ok {
|
||||
item := elem.Value.(*cacheItem)
|
||||
c.currentSize -= item.size
|
||||
c.evictList.Remove(elem)
|
||||
delete(c.items, key)
|
||||
}
|
||||
return c.d.Erase(key)
|
||||
}
|
||||
|
||||
// Clear flushes all cached elements.
|
||||
func (c *DiskCache) Clear() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.currentSize = 0
|
||||
c.items = make(map[string]*list.Element)
|
||||
c.evictList.Init()
|
||||
|
||||
return c.d.EraseAll()
|
||||
}
|
||||
|
||||
// Status returns the cache status.
|
||||
func (c *DiskCache) Status() Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return Status{
|
||||
TotalSize: c.currentSize,
|
||||
KeysCount: len(c.items),
|
||||
MaxSizeMB: c.maxSize / (1024 * 1024),
|
||||
TTLMinutes: int64(c.defaultTTL.Minutes()),
|
||||
LRUEnabled: c.lruEnabled,
|
||||
BasePath: c.basePath,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadConfig reloads policies from database configs dynamically.
|
||||
func (c *DiskCache) ReloadConfig(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Ensure DB is initialized before querying
|
||||
if db.DB(ctx) == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Max Size
|
||||
var scMaxSize model.SystemConfig
|
||||
maxSizeMB := int64(defaultMaxSizeMB)
|
||||
if err := scMaxSize.GetByKey(ctx, model.ConfigKeyDiskCacheMaxSizeMB); err == nil && scMaxSize.Value != "" {
|
||||
if val, err := strconv.ParseInt(scMaxSize.Value, 10, 64); err == nil && val > 0 {
|
||||
maxSizeMB = val
|
||||
}
|
||||
}
|
||||
c.maxSize = maxSizeMB * 1024 * 1024
|
||||
|
||||
// 2. Default TTL
|
||||
var scTTL model.SystemConfig
|
||||
ttlMinutes := int64(defaultTTLMinutes)
|
||||
if err := scTTL.GetByKey(ctx, model.ConfigKeyDiskCacheTTLMinutes); err == nil && scTTL.Value != "" {
|
||||
if val, err := strconv.ParseInt(scTTL.Value, 10, 64); err == nil && val >= 0 {
|
||||
ttlMinutes = val
|
||||
}
|
||||
}
|
||||
c.defaultTTL = time.Duration(ttlMinutes) * time.Minute
|
||||
|
||||
// 3. LRU Enabled
|
||||
var scLRU model.SystemConfig
|
||||
lruEnabled := true
|
||||
if err := scLRU.GetByKey(ctx, model.ConfigKeyDiskCacheLRUEnabled); err == nil && scLRU.Value != "" {
|
||||
if val, err := strconv.ParseBool(scLRU.Value); err == nil {
|
||||
lruEnabled = val
|
||||
}
|
||||
}
|
||||
c.lruEnabled = lruEnabled
|
||||
|
||||
// Apply eviction immediately under new configs
|
||||
c.evict()
|
||||
}
|
||||
|
||||
// evict evicts oldest items if current size exceeds maxSize and LRU is enabled.
|
||||
func (c *DiskCache) evict() {
|
||||
if !c.lruEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
|
||||
elem := c.evictList.Back()
|
||||
if elem == nil {
|
||||
break
|
||||
}
|
||||
item := elem.Value.(*cacheItem)
|
||||
key := item.key
|
||||
|
||||
// Remove from memory
|
||||
c.currentSize -= item.size
|
||||
c.evictList.Remove(elem)
|
||||
delete(c.items, key)
|
||||
|
||||
// Delete from disk
|
||||
_ = c.d.Erase(key)
|
||||
}
|
||||
}
|
||||
|
||||
// loadTracker walks the directory to rebuild the LRU and size tracking structures.
|
||||
func (c *DiskCache) loadTracker() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.currentSize = 0
|
||||
c.items = make(map[string]*list.Element)
|
||||
c.evictList = list.New()
|
||||
|
||||
files, err := os.ReadDir(c.basePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type tempItem struct {
|
||||
key string
|
||||
size int64
|
||||
expiredAt time.Time
|
||||
mtime time.Time
|
||||
}
|
||||
var loadedItems []tempItem
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := file.Name()
|
||||
// Skip temporary files
|
||||
if strings.HasPrefix(name, ".") || strings.Contains(name, "temp") {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(c.basePath, name)
|
||||
// #nosec G304
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var expiredAt time.Time
|
||||
var expNano int64
|
||||
err = binary.Read(f, binary.BigEndian, &expNano)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
// Corrupt metadata header: delete file
|
||||
_ = os.Remove(filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if expNano > 0 {
|
||||
expiredAt = time.Unix(0, expNano)
|
||||
// Expired: delete file
|
||||
if time.Now().After(expiredAt) {
|
||||
_ = os.Remove(filePath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
loadedItems = append(loadedItems, tempItem{
|
||||
key: name,
|
||||
size: info.Size(),
|
||||
expiredAt: expiredAt,
|
||||
mtime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort loaded items by modification time ascending (oldest first)
|
||||
sort.Slice(loadedItems, func(i, j int) bool {
|
||||
return loadedItems[i].mtime.Before(loadedItems[j].mtime)
|
||||
})
|
||||
|
||||
// Populate LRU (PushFront so that newest items are at the front, oldest at the back)
|
||||
for _, item := range loadedItems {
|
||||
entry := &cacheItem{
|
||||
key: item.key,
|
||||
size: item.size,
|
||||
expiredAt: item.expiredAt,
|
||||
}
|
||||
element := c.evictList.PushFront(entry)
|
||||
c.items[item.key] = element
|
||||
c.currentSize += item.size
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startCleanupWorker periodically cleans up expired cache items.
|
||||
func (c *DiskCache) startCleanupWorker(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
for range ticker.C {
|
||||
c.cleanExpired()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanExpired scans memory for expired items and removes them.
|
||||
func (c *DiskCache) cleanExpired() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, elem := range c.items {
|
||||
item := elem.Value.(*cacheItem)
|
||||
if !item.expiredAt.IsZero() && now.After(item.expiredAt) {
|
||||
c.currentSize -= item.size
|
||||
c.evictList.Remove(elem)
|
||||
delete(c.items, key)
|
||||
_ = c.d.Erase(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package diskcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
)
|
||||
|
||||
func TestDiskCacheBasic(t *testing.T) {
|
||||
testDir := "uploads/test_diskcache_basic"
|
||||
defer func() { _ = os.RemoveAll(testDir) }()
|
||||
_ = os.RemoveAll(testDir)
|
||||
|
||||
c := New(testDir)
|
||||
defer func() { _ = c.Clear() }()
|
||||
|
||||
key := "key1"
|
||||
val := []byte("value1")
|
||||
|
||||
// Get non-existent
|
||||
_, err := c.Get(key)
|
||||
if err != ErrCacheMiss {
|
||||
t.Fatalf("expected ErrCacheMiss, got %v", err)
|
||||
}
|
||||
|
||||
// Set & Get
|
||||
err = c.Set(key, val, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set cache: %v", err)
|
||||
}
|
||||
|
||||
got, err := c.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cache: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, val) {
|
||||
t.Errorf("expected %s, got %s", val, got)
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = c.Delete(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete: %v", err)
|
||||
}
|
||||
|
||||
_, err = c.Get(key)
|
||||
if err != ErrCacheMiss {
|
||||
t.Errorf("expected ErrCacheMiss after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskCacheTTL(t *testing.T) {
|
||||
testDir := "uploads/test_diskcache_ttl"
|
||||
defer func() { _ = os.RemoveAll(testDir) }()
|
||||
_ = os.RemoveAll(testDir)
|
||||
|
||||
c := New(testDir)
|
||||
defer func() { _ = c.Clear() }()
|
||||
|
||||
key := "ttlkey"
|
||||
val := []byte("ttlval")
|
||||
|
||||
// Set with 200ms TTL
|
||||
err := c.Set(key, val, 200*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set: %v", err)
|
||||
}
|
||||
|
||||
// Immediate Get should succeed
|
||||
got, err := c.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, val) {
|
||||
t.Errorf("expected %s, got %s", val, got)
|
||||
}
|
||||
|
||||
// Sleep 250ms to expire
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Get should fail with cache miss
|
||||
_, err = c.Get(key)
|
||||
if err != ErrCacheMiss {
|
||||
t.Errorf("expected ErrCacheMiss after TTL expiration, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskCacheLRUEviction(t *testing.T) {
|
||||
testDir := "uploads/test_diskcache_lru"
|
||||
defer func() { _ = os.RemoveAll(testDir) }()
|
||||
_ = os.RemoveAll(testDir)
|
||||
|
||||
c := New(testDir)
|
||||
defer func() { _ = c.Clear() }()
|
||||
|
||||
// Force a very small max size of 20 bytes for testing (8 bytes header + payload)
|
||||
// So 2 items of 2 bytes payload = 2 * (8 + 2) = 20 bytes max.
|
||||
c.maxSize = 20
|
||||
c.lruEnabled = true
|
||||
|
||||
// Write item 1: 8 + 2 = 10 bytes
|
||||
err := c.Set("k1", []byte("v1"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set k1: %v", err)
|
||||
}
|
||||
|
||||
// Write item 2: 8 + 2 = 10 bytes
|
||||
err = c.Set("k2", []byte("v2"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set k2: %v", err)
|
||||
}
|
||||
|
||||
// Both should exist
|
||||
if _, err := c.Get("k1"); err != nil {
|
||||
t.Errorf("k1 should exist: %v", err)
|
||||
}
|
||||
if _, err := c.Get("k2"); err != nil {
|
||||
t.Errorf("k2 should exist: %v", err)
|
||||
}
|
||||
|
||||
// Write item 3: 8 + 2 = 10 bytes -> total size would be 30, exceeding 20.
|
||||
// This should evict the oldest item. Since k1 was accessed, but then k2 was accessed,
|
||||
// wait, let's access k1 again to make it the most recently used, so k2 becomes oldest!
|
||||
_, _ = c.Get("k1") // k1 is now MRU, k2 is LRU
|
||||
|
||||
err = c.Set("k3", []byte("v3"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set k3: %v", err)
|
||||
}
|
||||
|
||||
// k2 should be evicted, k1 and k3 should exist
|
||||
_, err = c.Get("k2")
|
||||
if err != ErrCacheMiss {
|
||||
t.Errorf("expected k2 to be evicted, got error %v", err)
|
||||
}
|
||||
|
||||
if _, err := c.Get("k1"); err != nil {
|
||||
t.Errorf("k1 should still exist: %v", err)
|
||||
}
|
||||
|
||||
if _, err := c.Get("k3"); err != nil {
|
||||
t.Errorf("k3 should exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskCacheReloadConfig(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
testDir := "uploads/test_diskcache_reload"
|
||||
defer func() { _ = os.RemoveAll(testDir) }()
|
||||
_ = os.RemoveAll(testDir)
|
||||
|
||||
c := New(testDir)
|
||||
defer func() { _ = c.Clear() }()
|
||||
|
||||
// Update DB config values
|
||||
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheMaxSizeMB).Update("value", "250")
|
||||
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheTTLMinutes).Update("value", "120")
|
||||
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheLRUEnabled).Update("value", "false")
|
||||
|
||||
// Invalidate Redis config cache to force DB reload
|
||||
if db.Redis != nil {
|
||||
db.Redis.Del(context.Background(), db.PrefixedKey(model.SystemConfigRedisHashKey))
|
||||
}
|
||||
|
||||
// Reload config
|
||||
c.ReloadConfig(context.Background())
|
||||
|
||||
status := c.Status()
|
||||
if status.MaxSizeMB != 250 {
|
||||
t.Errorf("expected MaxSizeMB to be 250, got %d", status.MaxSizeMB)
|
||||
}
|
||||
if status.TTLMinutes != 120 {
|
||||
t.Errorf("expected TTLMinutes to be 120, got %d", status.TTLMinutes)
|
||||
}
|
||||
if status.LRUEnabled != false {
|
||||
t.Errorf("expected LRUEnabled to be false, got %t", status.LRUEnabled)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ const (
|
||||
ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串)
|
||||
ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索
|
||||
ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式)
|
||||
ConfigKeyDiskCacheMaxSizeMB = "disk_cache_max_size_mb" // 磁盘缓存最大空间大小 (MB)
|
||||
ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟)
|
||||
ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source"
|
||||
admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache"
|
||||
admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage"
|
||||
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
|
||||
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
||||
@@ -229,6 +230,11 @@ func registerRoutes(r *gin.Engine) {
|
||||
adminRouter.GET("/db-manage/table-data", admin_db_manage.GetDBTableData)
|
||||
adminRouter.POST("/db-manage/query", admin_db_manage.ExecuteSQL)
|
||||
|
||||
// Cache management
|
||||
adminRouter.GET("/cache/status", admin_cache.GetCacheStatus)
|
||||
adminRouter.POST("/cache/config", admin_cache.UpdateCacheConfig)
|
||||
adminRouter.POST("/cache/clear", admin_cache.ClearCache)
|
||||
|
||||
// System logs
|
||||
adminRouter.GET("/logs", admin_logs.GetLogs)
|
||||
adminRouter.GET("/logs/access", admin_logs.GetAccessLogs)
|
||||
|
||||
@@ -74,8 +74,8 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
||||
return sqliteDB, mr, cleanup
|
||||
}
|
||||
|
||||
func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
defaultConfigs := []model.SystemConfig{
|
||||
func getSeedConfigsPart1() []model.SystemConfig {
|
||||
return []model.SystemConfig{
|
||||
{
|
||||
Key: model.ConfigKeyUploadAllowedExtensions,
|
||||
Value: "jpg,png,webp",
|
||||
@@ -160,6 +160,11 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
Type: "system",
|
||||
Description: "人机验证兑换凭证有效时间(秒)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getSeedConfigsPart2() []model.SystemConfig {
|
||||
return []model.SystemConfig{
|
||||
{
|
||||
Key: model.ConfigKeyServerAddress,
|
||||
Value: "",
|
||||
@@ -220,7 +225,29 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
Type: "system",
|
||||
Description: "免登录访问的文件业务类型白名单",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyDiskCacheMaxSizeMB,
|
||||
Value: "100",
|
||||
Type: "system",
|
||||
Description: "磁盘缓存最大空间大小 (MB)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyDiskCacheTTLMinutes,
|
||||
Value: "60",
|
||||
Type: "system",
|
||||
Description: "磁盘缓存默认有效期 (分钟)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyDiskCacheLRUEnabled,
|
||||
Value: "true",
|
||||
Type: "system",
|
||||
Description: "是否启用 LRU 淘汰机制",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
defaultConfigs := append(getSeedConfigsPart1(), getSeedConfigsPart2()...)
|
||||
|
||||
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||
t.Fatalf("failed to seed default system configs: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user