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
{
@@ -426,7 +426,7 @@ export function FilesMain() {
{detailTarget.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
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 {