From f14875a8dea530639b02806d1c7c13eed57271e9 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 11 Jun 2026 15:57:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BC=93=E5=AD=98=E4=B8=8D?= =?UTF-8?q?=E8=BF=87=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- internal/apps/upload/file_server.go | 2 +- internal/diskcache/cache.go | 12 +++-- internal/diskcache/cache_test.go | 69 +++++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba45e40e..4bf08b5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ ## 缓存与文件能力 -- `internal/diskcache`:通过 `diskcache.GetGlobalCache()` 提供字节缓存读写、TTL、最大空间限制、LRU 淘汰、清空、状态统计和配置热更新。 +- `internal/diskcache`:通过 `diskcache.GetGlobalCache()` 提供字节缓存读写、TTL、最大空间限制、LRU 淘汰、清空、状态统计和配置热更新。写入时使用 `DefaultExpiration`(全局默认 TTL)、正数 `time.Duration`(业务 TTL)或 `NoExpiration`(值为 `-1`,永不过期);永久缓存仍受空间限制和 LRU 淘汰。 - `internal/storage`:提供 S3 兼容对象上传、读取、删除、CDN/代理读取及远端文件本地缓存。 - `internal/apps/upload`:提供上传记录、文件访问控制、本地/S3 文件响应、下载及图片 WebP 压缩;业务应复用这些入口,不直接操作底层文件。 diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 4e00168e..607d0c82 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -121,7 +121,7 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { return } - if err := cache.Set(cacheKey, webpBytes, 0); err != nil { + if err := cache.Set(cacheKey, webpBytes, diskcache.NoExpiration); err != nil { logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err) } diff --git a/internal/diskcache/cache.go b/internal/diskcache/cache.go index 387a41f6..9721d577 100644 --- a/internal/diskcache/cache.go +++ b/internal/diskcache/cache.go @@ -30,6 +30,11 @@ const ( defaultMaxSizeMB = 100 defaultTTLMinutes = 60 defaultCleanupInterval = 10 + + // DefaultExpiration applies the cache-wide default TTL. + DefaultExpiration time.Duration = 0 + // NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply. + NoExpiration time.Duration = -1 ) var ( @@ -103,13 +108,14 @@ func New(basePath string) *DiskCache { return c } -// Set stores a key-value pair in the cache with a specific TTL. -// If ttl <= 0, the default cache TTL is used. +// Set stores a key-value pair in the cache. +// Use DefaultExpiration for the configured default TTL, NoExpiration for no +// TTL, or a positive duration for a business-specific TTL. func (c *DiskCache) Set(key string, value []byte, ttl time.Duration) error { c.mu.Lock() defer c.mu.Unlock() - if ttl <= 0 { + if ttl == DefaultExpiration { ttl = c.defaultTTL } diff --git a/internal/diskcache/cache_test.go b/internal/diskcache/cache_test.go index 34b8938b..af46f260 100644 --- a/internal/diskcache/cache_test.go +++ b/internal/diskcache/cache_test.go @@ -95,6 +95,69 @@ func TestDiskCacheTTL(t *testing.T) { } } +func TestDiskCacheExpirationPolicies(t *testing.T) { + testDir := "uploads/test_diskcache_expiration_policies" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + defer func() { _ = c.Clear() }() + c.defaultTTL = 50 * time.Millisecond + + if err := c.Set("default", []byte("default"), DefaultExpiration); err != nil { + t.Fatalf("Set(default, DefaultExpiration) returned error: %v", err) + } + if err := c.Set("custom", []byte("custom"), 100*time.Millisecond); err != nil { + t.Fatalf("Set(custom, 100ms) returned error: %v", err) + } + if err := c.Set("permanent", []byte("permanent"), NoExpiration); err != nil { + t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) + } + + time.Sleep(75 * time.Millisecond) + + if _, err := c.Get("default"); err != ErrCacheMiss { + t.Errorf("Get(default) error = %v, want ErrCacheMiss", err) + } + if _, err := c.Get("custom"); err != nil { + t.Errorf("Get(custom) returned error before custom TTL elapsed: %v", err) + } + if _, err := c.Get("permanent"); err != nil { + t.Errorf("Get(permanent) returned error: %v", err) + } + + time.Sleep(50 * time.Millisecond) + + if _, err := c.Get("custom"); err != ErrCacheMiss { + t.Errorf("Get(custom) error = %v, want ErrCacheMiss", err) + } + if _, err := c.Get("permanent"); err != nil { + t.Errorf("Get(permanent) returned error after other entries expired: %v", err) + } +} + +func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) { + testDir := "uploads/test_diskcache_no_expiration_reload" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + if err := c.Set("permanent", []byte("value"), NoExpiration); err != nil { + t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) + } + + reloaded := New(testDir) + defer func() { _ = reloaded.Clear() }() + + got, err := reloaded.Get("permanent") + if err != nil { + t.Fatalf("reloaded Get(permanent) returned error: %v", err) + } + if !bytes.Equal(got, []byte("value")) { + t.Errorf("reloaded Get(permanent) = %q, want %q", got, "value") + } +} + func TestDiskCacheLRUEviction(t *testing.T) { testDir := "uploads/test_diskcache_lru" defer func() { _ = os.RemoveAll(testDir) }() @@ -109,13 +172,13 @@ func TestDiskCacheLRUEviction(t *testing.T) { c.lruEnabled = true // Write item 1: 8 + 2 = 10 bytes - err := c.Set("k1", []byte("v1"), 0) + err := c.Set("k1", []byte("v1"), DefaultExpiration) 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) + err = c.Set("k2", []byte("v2"), DefaultExpiration) if err != nil { t.Fatalf("failed to set k2: %v", err) } @@ -133,7 +196,7 @@ func TestDiskCacheLRUEviction(t *testing.T) { // 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) + err = c.Set("k3", []byte("v3"), DefaultExpiration) if err != nil { t.Fatalf("failed to set k3: %v", err) }