新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
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
|
||
}
|