Files
jiang13-bbs/backend/service/blob_store.go
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

133 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}