feat: 媒体库 blob 去重存储与品牌/注册等前端调整

新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
2026-10-03 02:38:11 +08:00
parent 8cfdfcbbf0
commit 46e0cdc0b3
32 changed files with 1242 additions and 228 deletions

View File

@@ -0,0 +1,132 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 内容寻址存储(content-addressed storage):
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
// 公开与私有是独立命名空间(见 model.Blob 注释)。
// sha256Hex 计算字节的 SHA-256 hex
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
for {
var b model.Blob
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
b = model.Blob{
Hash: hash,
Public: public,
RefCount: 1,
Size: size,
MIME: mimeType,
ObjectID: objectID,
}
if err := db.Create(&b).Error; err != nil {
if isUniqueConflict(err) {
continue // 并发首建:改为走计数+1
}
return err
}
return nil
}
if err != nil {
return err
}
return db.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
}
}
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
var removed model.Blob
var last bool
err := db.Transaction(func(tx *gorm.DB) error {
var b model.Blob
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if b.RefCount <= 1 {
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
removed = b
last = true
}
return nil
}
return tx.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", b.RefCount-1).Error
})
if err != nil {
return model.Blob{}, false, err
}
return removed, last, nil
}
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
// src 与 dst 同一路径时不做操作。
func placeLocalBlob(src, dst string) error {
if abs, err := filepath.Abs(src); err == nil {
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
if src != dst {
_ = os.Remove(src)
}
return nil
}
if err := os.Rename(src, dst); err != nil {
_ = os.Remove(src)
return err
}
return nil
}