diff --git a/internal/storage/oss.go b/internal/storage/oss.go index 1517dfa..4839877 100644 --- a/internal/storage/oss.go +++ b/internal/storage/oss.go @@ -8,10 +8,8 @@ import ( "fmt" "io" "mime/multipart" - "time" "github.com/pkg/errors" - "github.com/thanhpk/randstr" "github.com/wuhan005/gadget" "github.com/tamakyi/TamaBox/internal/conf" @@ -19,7 +17,7 @@ import ( // UploadPictureToOSS upload user's avatar or background image to OSS. // It returns the uploaded asset URL. -func UploadPictureToOSS(file multipart.File, _ *multipart.FileHeader) (string, error) { +func UploadPictureToOSS(file multipart.File, fileHeader *multipart.FileHeader) (string, error) { // 读取文件头,校验是否为图片 header := make([]byte, 512) n, err := file.Read(header) @@ -43,12 +41,12 @@ func UploadPictureToOSS(file multipart.File, _ *multipart.FileHeader) (string, e return "", errors.Wrap(err, "get OSS bucket") } - now := time.Now() - year := now.Year() - month := int(now.Month()) - day := now.Day() - - key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15)) + // OSS 通道原样上传原图(不转 WebP),扩展名沿用原始文件名。 + ext := "" + if fileHeader != nil { + ext = originalExt(fileHeader.Filename) + } + key := uploadKey(ext) if err := gadget.Retry(5, func() error { if err := bucket.PutObject(key, file); err != nil { diff --git a/internal/storage/picture.go b/internal/storage/picture.go index 02c81b6..8f4d8b5 100644 --- a/internal/storage/picture.go +++ b/internal/storage/picture.go @@ -4,7 +4,46 @@ package storage -const PictureKeyPrefix = "picture/" +import ( + "fmt" + "path/filepath" + "strings" + "time" +) + +// PictureKeyPrefix 是图片对象 key 的前缀。生产环境图片以无前缀方式存于 +// bucket 根下(R2 PathStyle 下 bucket 名自动作为 URL 首段),故这里置空。 +const PictureKeyPrefix = "" + +// uploadKey 按生产口径生成对象 key:`YYYY/MM/`。 +// 无前缀、无 day 层,文件名用纳秒时间戳保证唯一,扩展名与最终落盘内容类型一致。 +func uploadKey(ext string) string { + now := time.Now() + return fmt.Sprintf("%s%d/%02d/%d%s", PictureKeyPrefix, now.Year(), int(now.Month()), now.UnixNano(), ext) +} + +// extFromContentType 由 MIME 类型推导扩展名,用于保证对象 key 的扩展名 +// 与 ContentType(以及实际落盘字节)严格一致,避免出现「.jpg 实为 webp」的错配。 +func extFromContentType(contentType string) string { + switch contentType { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return "" + } +} + +// originalExt 取原始文件名的扩展名,统一转小写。用于转换失败回退原图时 +// 保留原始扩展名(与生产口径一致)。 +func originalExt(filename string) string { + return strings.ToLower(filepath.Ext(filename)) +} // MaxAvatarSize is the max avatar size which is 2MB. const MaxAvatarSize = 2 * 1024 * 1024 diff --git a/internal/storage/picture_test.go b/internal/storage/picture_test.go new file mode 100644 index 0000000..56343f7 --- /dev/null +++ b/internal/storage/picture_test.go @@ -0,0 +1,46 @@ +package storage + +import ( + "regexp" + "testing" +) + +// 生产口径 key 结构:`YYYY/MM/`,无前缀、无 day 层。 +// 与 route/question/page.go:uploadImage 的落盘 key 保持一致。 +var prodKeyRe = regexp.MustCompile(`^\d{4}/\d{2}/\d{16,19}\.jpg$`) + +func TestUploadKeyFormat(t *testing.T) { + key := uploadKey(".jpg") + if !prodKeyRe.MatchString(key) { + t.Fatalf("uploadKey(.jpg) = %q, 不符合生产口径 YYYY/MM/", key) + } +} + +func TestExtFromContentType(t *testing.T) { + cases := map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": "", + } + for ct, want := range cases { + if got := extFromContentType(ct); got != want { + t.Errorf("extFromContentType(%q) = %q, want %q", ct, got, want) + } + } +} + +func TestOriginalExt(t *testing.T) { + cases := map[string]string{ + "photo.JPG": ".jpg", + "a.b.png": ".png", + "noext": "", + "tricky.PnG": ".png", + } + for name, want := range cases { + if got := originalExt(name); got != want { + t.Errorf("originalExt(%q) = %q, want %q", name, got, want) + } + } +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 7089889..45ab2e6 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -16,7 +16,6 @@ import ( "mime/multipart" "os" "os/exec" - "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -24,7 +23,6 @@ import ( "github.com/disintegration/imaging" "github.com/pkg/errors" "github.com/sirupsen/logrus" - "github.com/thanhpk/randstr" "github.com/wuhan005/gadget" "github.com/tamakyi/TamaBox/internal/conf" @@ -181,12 +179,9 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as } } - // 5. 生成存储路径(无后缀名) - now := time.Now() - year := now.Year() - month := int(now.Month()) - day := now.Day() - key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15)) + // 5. 生成存储路径:`YYYY/MM/`(生产口径,无前缀、无 day 层)。 + // 扩展名跟随最终落盘内容类型,保证 key 扩展名与 ContentType 严格一致。 + key := uploadKey(extFromContentType(contentType)) // 6. 获取共享 S3 客户端 client, err := GetS3Client() @@ -202,7 +197,7 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as Body: bytes.NewReader(uploadData), ContentLength: aws.Int64(int64(len(uploadData))), ContentType: aws.String(contentType), - // key 为 picture/日期/随机hex,内容不可变 → 可安全启用 immutable 强缓存, + // key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存, // 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。 CacheControl: aws.String("public, max-age=31536000, immutable"), })