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 }