diff --git a/.golangci.yml b/.golangci.yml index 8b0cf748..613a38e3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: # 代码坏味道 - dupl # 重复代码 - mnd # 魔法数字 + - goconst # 不必要的字符串常量 - cyclop # 包/函数复杂度 - nestif # if 嵌套太深 - maintidx # 维护性指数 diff --git a/docs/docs.go b/docs/docs.go index 81bf14cc..6b35f536 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3618,14 +3618,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)", - "name": "compress", - "in": "query" - }, - { - "type": "string", - "description": "压缩质量等级 (low, medium, high),默认为 high", - "name": "level", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", "in": "query" } ], @@ -4404,14 +4398,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)", - "name": "compress", - "in": "query" - }, - { - "type": "string", - "description": "压缩质量等级 (low, medium, high),默认为 high", - "name": "level", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", "in": "query" } ], diff --git a/docs/swagger.json b/docs/swagger.json index 06c659be..38ead169 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3611,14 +3611,8 @@ }, { "type": "string", - "description": "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)", - "name": "compress", - "in": "query" - }, - { - "type": "string", - "description": "压缩质量等级 (low, medium, high),默认为 high", - "name": "level", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", "in": "query" } ], @@ -4397,14 +4391,8 @@ }, { "type": "string", - "description": "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)", - "name": "compress", - "in": "query" - }, - { - "type": "string", - "description": "压缩质量等级 (low, medium, high),默认为 high", - "name": "level", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", "in": "query" } ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2420b0e9..a9b812a0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3195,13 +3195,9 @@ paths: name: id required: true type: string - - description: 是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略) + - description: 图片质量 (low, medium, high, origin),默认为 origin in: query - name: compress - type: string - - description: 压缩质量等级 (low, medium, high),默认为 high - in: query - name: level + name: quality type: string produces: - application/octet-stream @@ -3669,13 +3665,9 @@ paths: name: id required: true type: string - - description: 是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略) + - description: 图片质量 (low, medium, high, origin),默认为 origin in: query - name: compress - type: string - - description: 压缩质量等级 (low, medium, high),默认为 high - in: query - name: level + name: quality type: string produces: - application/octet-stream diff --git a/frontend/components/common/admin/files.tsx b/frontend/components/common/admin/files.tsx index 0a09319c..932713e4 100644 --- a/frontend/components/common/admin/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -35,7 +35,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet" -import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service" +import {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service" import type {Upload as UploadRecord} from "@/lib/services/upload/types" /* ─── 工具函数 ─────────────────────────────────────────── */ @@ -310,7 +310,7 @@ export function FilesMain() { {file.mime_type.startsWith("image/") ? ( // eslint-disable-next-line @next/next/no-img-element {file.file_name} { @@ -426,7 +426,7 @@ export function FilesMain() { {detailTarget.mime_type.startsWith("image/") ? ( // eslint-disable-next-line @next/next/no-img-element {detailTarget.file_name} diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 700945c4..d26d36b5 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -2,14 +2,21 @@ import {BaseService} from '../core/base.service' import type {ListUploadsResponse, Upload, UploadImageResponse} from './types' import type {InternalAxiosRequestConfig} from 'axios' +export type ImageQuality = 'low' | 'medium' | 'high' | 'origin' + /** * 根据上传ID构造文件访问URL * @param id - 上传记录ID + * @param quality - 图片质量 * @returns 文件访问URL */ -export function getFileUrl(id: string | number | null | undefined): string | null { +export function getFileUrl( + id: string | number | null | undefined, + quality: ImageQuality = 'origin' +): string | null { if (!id) return null - return `/f/${id}` + if (quality === 'origin') return `/f/${id}` + return `/f/${id}?quality=${quality}` } /** diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 7369c7bf..f8264455 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -33,8 +33,7 @@ import ( // @Tags upload // @Produce octet-stream // @Param id path string true "文件 ID" -// @Param compress query string false "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)" -// @Param level query string false "压缩质量等级 (low, medium, high),默认为 high" +// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" // @Success 200 {file} file "成功获取文件内容" // @Failure 400 {object} util.ResponseAny "文件 ID 格式错误" // @Failure 401 {object} util.ResponseAny "未登录" @@ -89,22 +88,16 @@ func getUploadRecordByID(c *gin.Context) (*model.Upload, error) { // ServeUpload 将已存在的文件内容读取并流式响应给客户端,支持本地和 S3/CDN 驱动,并可选支持 WebP 图片压缩与本地缓存。 func ServeUpload(c *gin.Context, upload *model.Upload) { - compressStr := c.Query("compress") + quality := normalizeImageQuality(c.Query("quality")) isImage := strings.HasPrefix(strings.ToLower(upload.MimeType), "image/") || isImageExtension(strings.ToLower(upload.Extension)) - if compressStr == "" || !isImage { + if quality == "origin" || !isImage { serveOriginal(c, upload) return } - // Map level parameter to standard options - level := strings.ToLower(c.Query("level")) - if level != "low" && level != "medium" && level != "high" { - level = "high" - } - cache := diskcache.GetGlobalCache() - cacheKey := imageCompressionCacheKey(upload, level) + cacheKey := imageCompressionCacheKey(upload, quality) if webpBytes, err := cache.Get(cacheKey); err == nil { c.Data(http.StatusOK, "image/webp", webpBytes) return @@ -121,7 +114,7 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { } // Compress to WebP - webpBytes, err := CompressImageToWebP(bytes.NewReader(origBytes), level) + webpBytes, err := CompressImageToWebP(bytes.NewReader(origBytes), quality) if err != nil { logger.ErrorF(c.Request.Context(), "failed to compress image to WebP: %v", err) serveOriginal(c, upload) @@ -136,17 +129,26 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { c.Data(http.StatusOK, "image/webp", webpBytes) } -func imageCompressionCacheKey(upload *model.Upload, level string) string { +func imageCompressionCacheKey(upload *model.Upload, quality string) string { return fmt.Sprintf( "upload_webp_v1_%d_%d_%d_%s_%s", upload.ID, upload.UpdatedAt.UnixNano(), upload.FileSize, upload.Hash, - level, + quality, ) } +func normalizeImageQuality(quality string) string { + switch strings.ToLower(quality) { + case "low", "medium", "high": + return strings.ToLower(quality) + default: + return "origin" + } +} + // 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 1151b641..76835b64 100644 --- a/internal/apps/upload/file_server_test.go +++ b/internal/apps/upload/file_server_test.go @@ -279,8 +279,8 @@ func TestImageCompression(t *testing.T) { } }) - t.Run("serve compressed WebP file with compress=true", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/f/3001?compress=true&level=medium", nil) + t.Run("serve compressed WebP file with medium quality", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -320,17 +320,43 @@ func TestImageCompression(t *testing.T) { } }) - t.Run("serve compressed with default quality high", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/f/3001?compress=true", nil) + t.Run("serve original file with origin quality", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=origin", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } - cacheKey := imageCompressionCacheKey(&uploadRecord, "high") - if _, err := cache.Get(cacheKey); err != nil { - t.Errorf("disk cache Get(%q) returned error: %v", cacheKey, err) + if w.Header().Get("Content-Type") != "image/png" { + t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type")) + } + if !bytes.Equal(w.Body.Bytes(), pngBuf.Bytes()) { + t.Errorf("origin-quality response differs from original image") } }) } + +func TestNormalizeImageQuality(t *testing.T) { + tests := []struct { + name string + quality string + want string + }{ + {name: "low", quality: "low", want: "low"}, + {name: "medium", quality: "medium", want: "medium"}, + {name: "high", quality: "high", want: "high"}, + {name: "origin", quality: "origin", want: "origin"}, + {name: "uppercase", quality: "LOW", want: "low"}, + {name: "empty", quality: "", want: "origin"}, + {name: "invalid", quality: "maximum", want: "origin"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeImageQuality(tt.quality); got != tt.want { + t.Errorf("normalizeImageQuality(%q) = %q, want %q", tt.quality, got, tt.want) + } + }) + } +} diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index 57141b77..024e98bf 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -174,8 +174,7 @@ func UploadFile(c *gin.Context) { // @Tags upload // @Produce octet-stream // @Param id path string true "文件 ID" -// @Param compress query string false "是否启用压缩 (传任意非空值代表启用,非图片文件将被忽略)" -// @Param level query string false "压缩质量等级 (low, medium, high),默认为 high" +// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" // @Security SessionCookie // @Success 200 {file} file "成功下载文件" // @Failure 400 {object} util.ResponseAny "参数错误" @@ -198,10 +197,10 @@ func DownloadFile(c *gin.Context) { } fileName := upload.FileName - compressStr := c.Query("compress") + quality := normalizeImageQuality(c.Query("quality")) isImage := strings.HasPrefix(strings.ToLower(upload.MimeType), "image/") || isImageExtension(strings.ToLower(upload.Extension)) - if compressStr != "" && isImage { + if quality != "origin" && isImage { ext := filepath.Ext(fileName) if ext != "" { fileName = strings.TrimSuffix(fileName, ext) + ".webp" diff --git a/internal/apps/upload/utils.go b/internal/apps/upload/utils.go index c30ba64c..bdb87ffb 100644 --- a/internal/apps/upload/utils.go +++ b/internal/apps/upload/utils.go @@ -43,8 +43,8 @@ func ValidateS3Key(key string) error { } // CompressImageToWebP decodes an image from srcReader and encodes it into WebP format -// using the specified quality level (low -> 60, medium -> 75, high -> 85). -func CompressImageToWebP(srcReader io.Reader, qualityLevel string) ([]byte, error) { +// using the specified quality (low -> 60, medium -> 75, high -> 85). +func CompressImageToWebP(srcReader io.Reader, quality string) ([]byte, error) { // Decode the image img, format, err := image.Decode(srcReader) if err != nil { @@ -52,22 +52,22 @@ func CompressImageToWebP(srcReader io.Reader, qualityLevel string) ([]byte, erro } // Determine quality - var quality float32 - switch strings.ToLower(qualityLevel) { + var qualityScore float32 + switch strings.ToLower(quality) { case "low": - quality = 60 + qualityScore = 60 case "medium": - quality = 75 + qualityScore = 75 case "high", "": - quality = 85 + qualityScore = 85 default: - quality = 85 + qualityScore = 85 } // Encode to WebP var buf bytes.Buffer err = webp.Encode(&buf, img, &webp.EncoderOptions{ - Quality: quality, + Quality: qualityScore, Method: 4, // Default method }) if err != nil {