feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
@@ -113,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
||||
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + ".webp"
|
||||
hash := sha256Hex(data)
|
||||
filename := hash + ".webp"
|
||||
fullPath := filepath.Join(s.dir, "avatars", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
url := "/uploads/avatars/" + filename
|
||||
var objectID string
|
||||
writeLocal := func() error {
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
return nil // 同内容头像已落盘,字节必然一致,无需重写
|
||||
}
|
||||
return os.WriteFile(fullPath, data, 0o644)
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindAvatar,
|
||||
URL: "/uploads/avatars/" + filename,
|
||||
MIME: "image/webp",
|
||||
Size: len(data),
|
||||
Width: cfg.Width,
|
||||
Height: cfg.Height,
|
||||
}
|
||||
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
if err := writeLocal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, e
|
||||
}
|
||||
objectID = id
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
url = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
} else if err := writeLocal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 附件记录与用户头像更新在同一事务内完成
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindAvatar,
|
||||
URL: url,
|
||||
BlobHash: hash,
|
||||
MIME: "image/webp",
|
||||
Size: len(data),
|
||||
Width: cfg.Width,
|
||||
Height: cfg.Height,
|
||||
}
|
||||
// 附件记录、blob 引用与用户头像更新在同一事务内完成
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
|
||||
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -351,29 +362,51 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*m
|
||||
}
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
Source: normalizeImageSource(source),
|
||||
URL: "/uploads/images/" + storeName,
|
||||
MIME: storeMime,
|
||||
Size: int(storeSize),
|
||||
Width: w,
|
||||
Height: h,
|
||||
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
|
||||
hash, err := hashFile(storePath)
|
||||
if err != nil {
|
||||
_ = os.Remove(storePath)
|
||||
return nil, err
|
||||
}
|
||||
finalName := hash + strings.ToLower(filepath.Ext(storeName))
|
||||
url := "/uploads/images/" + finalName
|
||||
var objectID string
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(storePath, att.MIME, true)
|
||||
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(storePath)
|
||||
return nil, e
|
||||
}
|
||||
objectID = id
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
|
||||
url = "/api/media/" + id
|
||||
_ = os.Remove(storePath)
|
||||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(storePath)
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
Source: normalizeImageSource(source),
|
||||
URL: url,
|
||||
BlobHash: hash,
|
||||
MIME: storeMime,
|
||||
Size: int(storeSize),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(att).Error
|
||||
}); err != nil {
|
||||
s.releaseBlobBestEffort(hash, true, url)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -722,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if att.BlobHash != "" {
|
||||
return s.releaseAttachmentBlob(&att)
|
||||
}
|
||||
|
||||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||
@@ -736,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
|
||||
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
|
||||
if err != nil || !last {
|
||||
return err
|
||||
}
|
||||
return s.removePublicBlobObject(b, att.URL)
|
||||
}
|
||||
|
||||
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
|
||||
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
|
||||
b, last, err := releaseBlobRef(s.db, hash, public)
|
||||
if err != nil {
|
||||
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||
return
|
||||
}
|
||||
if !last {
|
||||
return
|
||||
}
|
||||
if err := s.removePublicBlobObject(b, localURL); err != nil {
|
||||
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
|
||||
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
|
||||
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
|
||||
if b.ObjectID != "" && s.ops != nil {
|
||||
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
|
||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if localURL == "" {
|
||||
return nil
|
||||
}
|
||||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
|
||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||
|
||||
Reference in New Issue
Block a user