From ad97ca7df10f869e223e4dc06225358f826d23e9 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 11 Jun 2026 15:23:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8E=8B=E7=BC=A9=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 ++++ internal/apps/upload/constants.go | 2 -- internal/apps/upload/file_server.go | 34 ++++++++++-------- internal/apps/upload/file_server_test.go | 45 ++++++++++++++++-------- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68c7a57b..ba45e40e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,13 @@ - 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。 - 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。 - 在提交更改前运行 `make code-check`。 +- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。 + +## 缓存与文件能力 + +- `internal/diskcache`:通过 `diskcache.GetGlobalCache()` 提供字节缓存读写、TTL、最大空间限制、LRU 淘汰、清空、状态统计和配置热更新。 +- `internal/storage`:提供 S3 兼容对象上传、读取、删除、CDN/代理读取及远端文件本地缓存。 +- `internal/apps/upload`:提供上传记录、文件访问控制、本地/S3 文件响应、下载及图片 WebP 压缩;业务应复用这些入口,不直接操作底层文件。 ## 常用命令 diff --git a/internal/apps/upload/constants.go b/internal/apps/upload/constants.go index 4ca4ad55..ad1c41f3 100644 --- a/internal/apps/upload/constants.go +++ b/internal/apps/upload/constants.go @@ -8,6 +8,4 @@ const ( detectContentBytes = 512 // http.DetectContentType 需要的最小字节数 uploadDirPerm = 0755 // 上传目录权限 uploadFilePerm = 0644 // 上传文件权限 - cacheDirPerm = 0750 // 缓存目录权限 (gosec) - cacheFilePerm = 0600 // 缓存文件权限 (gosec) ) diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index a9275c96..7369c7bf 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -13,13 +13,13 @@ import ( "io" "net/http" "os" - "path/filepath" "strconv" "strings" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/internal/diskcache" "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" @@ -103,14 +103,13 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { level = "high" } - // Local cache path for the compressed webp image - cachePath := filepath.Join("uploads", "cache", fmt.Sprintf("compressed_%d_%s.webp", upload.ID, level)) - - // Check if the compressed file already exists in cache - if _, err := os.Stat(cachePath); err == nil { - c.Header("Content-Type", "image/webp") - c.File(cachePath) + cache := diskcache.GetGlobalCache() + cacheKey := imageCompressionCacheKey(upload, level) + if webpBytes, err := cache.Get(cacheKey); err == nil { + c.Data(http.StatusOK, "image/webp", webpBytes) return + } else if !errors.Is(err, diskcache.ErrCacheMiss) { + logger.WarnF(c.Request.Context(), "failed to read compressed image cache: %v", err) } // Cache miss: retrieve original file content @@ -129,18 +128,25 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { return } - // Ensure cache directory exists and write cached file - if err := os.MkdirAll(filepath.Dir(cachePath), cacheDirPerm); err != nil { - logger.ErrorF(c.Request.Context(), "failed to create cache directory: %v", err) - } else if err := os.WriteFile(cachePath, webpBytes, cacheFilePerm); err != nil { - logger.ErrorF(c.Request.Context(), "failed to write compressed cache file: %v", err) + if err := cache.Set(cacheKey, webpBytes, 0); err != nil { + logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err) } // Serve compressed WebP - c.Header("Content-Type", "image/webp") c.Data(http.StatusOK, "image/webp", webpBytes) } +func imageCompressionCacheKey(upload *model.Upload, level string) string { + return fmt.Sprintf( + "upload_webp_v1_%d_%d_%d_%s_%s", + upload.ID, + upload.UpdatedAt.UnixNano(), + upload.FileSize, + upload.Hash, + level, + ) +} + // serveOriginal 原始文件的流式响应逻辑 func serveOriginal(c *gin.Context, upload *model.Upload) { if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) { diff --git a/internal/apps/upload/file_server_test.go b/internal/apps/upload/file_server_test.go index e60cc6c8..1151b641 100644 --- a/internal/apps/upload/file_server_test.go +++ b/internal/apps/upload/file_server_test.go @@ -13,10 +13,10 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "testing" "github.com/Rain-kl/Wavelet/internal/common" + "github.com/Rain-kl/Wavelet/internal/diskcache" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/gin-contrib/sessions" @@ -205,10 +205,20 @@ func TestImageCompression(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() + cache := diskcache.GetGlobalCache() + if err := cache.Clear(); err != nil { + t.Fatalf("failed to clear disk cache before test: %v", err) + } + // Ensure uploads dir is cleaned up defer func() { _ = os.RemoveAll("uploads") }() + defer func() { + if err := cache.Clear(); err != nil { + t.Errorf("failed to clear disk cache after test: %v", err) + } + }() // Create test user user := model.User{ @@ -282,25 +292,31 @@ func TestImageCompression(t *testing.T) { t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) } - // Check if local cache file was created - cachePath := filepath.Join("uploads", "cache", "compressed_3001_medium.webp") - if _, err := os.Stat(cachePath); os.IsNotExist(err) { - t.Errorf("expected cached webp file to be created at %s, but it doesn't exist", cachePath) + cacheKey := imageCompressionCacheKey(&uploadRecord, "medium") + cachedBytes, err := cache.Get(cacheKey) + if err != nil { + t.Fatalf("disk cache Get(%q) returned error: %v", cacheKey, err) + } + if !bytes.Equal(cachedBytes, w.Body.Bytes()) { + t.Errorf("cached compressed image differs from response") } - // Subsequent request should hit the cache (modify the cached file to verify) - testBytes := []byte("cached webp content") - if err := os.WriteFile(cachePath, testBytes, 0644); err != nil { - t.Fatalf("failed to write test bytes to cache: %v", err) + if err := os.Remove(filePath); err != nil { + t.Fatalf("failed to remove source image before cache-hit request: %v", err) } + t.Cleanup(func() { + if err := os.WriteFile(filePath, pngBuf.Bytes(), 0644); err != nil { + t.Errorf("failed to restore source image: %v", err) + } + }) w2 := httptest.NewRecorder() r.ServeHTTP(w2, req) if w2.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w2.Code) } - if string(w2.Body.Bytes()) != "cached webp content" { - t.Errorf("expected cached content, got %s", string(w2.Body.Bytes())) + if !bytes.Equal(w2.Body.Bytes(), cachedBytes) { + t.Errorf("cache-hit response differs from cached compressed image") } }) @@ -312,10 +328,9 @@ func TestImageCompression(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } - // Check if local cache file with "high" was created - cachePath := filepath.Join("uploads", "cache", "compressed_3001_high.webp") - if _, err := os.Stat(cachePath); os.IsNotExist(err) { - t.Errorf("expected cached webp file to be created at %s for default level", cachePath) + cacheKey := imageCompressionCacheKey(&uploadRecord, "high") + if _, err := cache.Get(cacheKey); err != nil { + t.Errorf("disk cache Get(%q) returned error: %v", cacheKey, err) } }) }