storage: 对齐图片对象 key 到生产口径
生产真实 key 为 `YYYY/MM/<UnixNano>.<ext>`(无前缀、无 day 层), 与 route/question/page.go:uploadImage 一致;internal/storage 里三个 上传函数仍用旧的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`,从未上线。 - PictureKeyPrefix 置空,统一由 uploadKey 生成 key - 扩展名跟随最终 ContentType(webp 转成功用 .webp,回退原图用原格式) - OSS 原图直传通道取原文件名扩展名 - 新增 picture_test.go 锁定 key 格式,防回归
This commit is contained in:
@@ -8,10 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/thanhpk/randstr"
|
|
||||||
"github.com/wuhan005/gadget"
|
"github.com/wuhan005/gadget"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
@@ -19,7 +17,7 @@ import (
|
|||||||
|
|
||||||
// UploadPictureToOSS upload user's avatar or background image to OSS.
|
// UploadPictureToOSS upload user's avatar or background image to OSS.
|
||||||
// It returns the uploaded asset URL.
|
// 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)
|
header := make([]byte, 512)
|
||||||
n, err := file.Read(header)
|
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")
|
return "", errors.Wrap(err, "get OSS bucket")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
// OSS 通道原样上传原图(不转 WebP),扩展名沿用原始文件名。
|
||||||
year := now.Year()
|
ext := ""
|
||||||
month := int(now.Month())
|
if fileHeader != nil {
|
||||||
day := now.Day()
|
ext = originalExt(fileHeader.Filename)
|
||||||
|
}
|
||||||
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
|
key := uploadKey(ext)
|
||||||
|
|
||||||
if err := gadget.Retry(5, func() error {
|
if err := gadget.Retry(5, func() error {
|
||||||
if err := bucket.PutObject(key, file); err != nil {
|
if err := bucket.PutObject(key, file); err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,46 @@
|
|||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
const PictureKeyPrefix = "picture/"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PictureKeyPrefix 是图片对象 key 的前缀。生产环境图片以无前缀方式存于
|
||||||
|
// bucket 根下(R2 PathStyle 下 bucket 名自动作为 URL 首段),故这里置空。
|
||||||
|
const PictureKeyPrefix = ""
|
||||||
|
|
||||||
|
// uploadKey 按生产口径生成对象 key:`YYYY/MM/<UnixNano><ext>`。
|
||||||
|
// 无前缀、无 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.
|
// MaxAvatarSize is the max avatar size which is 2MB.
|
||||||
const MaxAvatarSize = 2 * 1024 * 1024
|
const MaxAvatarSize = 2 * 1024 * 1024
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 生产口径 key 结构:`YYYY/MM/<UnixNano><ext>`,无前缀、无 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/<UnixNano><ext>", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/thanhpk/randstr"
|
|
||||||
"github.com/wuhan005/gadget"
|
"github.com/wuhan005/gadget"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
@@ -181,12 +179,9 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 生成存储路径(无后缀名)
|
// 5. 生成存储路径:`YYYY/MM/<UnixNano><ext>`(生产口径,无前缀、无 day 层)。
|
||||||
now := time.Now()
|
// 扩展名跟随最终落盘内容类型,保证 key 扩展名与 ContentType 严格一致。
|
||||||
year := now.Year()
|
key := uploadKey(extFromContentType(contentType))
|
||||||
month := int(now.Month())
|
|
||||||
day := now.Day()
|
|
||||||
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
|
|
||||||
|
|
||||||
// 6. 获取共享 S3 客户端
|
// 6. 获取共享 S3 客户端
|
||||||
client, err := GetS3Client()
|
client, err := GetS3Client()
|
||||||
@@ -202,7 +197,7 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
|
|||||||
Body: bytes.NewReader(uploadData),
|
Body: bytes.NewReader(uploadData),
|
||||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||||
ContentType: aws.String(contentType),
|
ContentType: aws.String(contentType),
|
||||||
// key 为 picture/日期/随机hex,内容不可变 → 可安全启用 immutable 强缓存,
|
// key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存,
|
||||||
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
|
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
|
||||||
CacheControl: aws.String("public, max-age=31536000, immutable"),
|
CacheControl: aws.String("public, max-age=31536000, immutable"),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user