feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
@@ -262,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||
if errors.Is(e, service.ErrEmailRegistered) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
if !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
|
||||
BrandMark *string `json:"brand_mark"`
|
||||
BrandLogoSize *string `json:"brand_logo_size"`
|
||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||
BrandSloganVisible *bool `json:"brand_slogan_visible"`
|
||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||
|
||||
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||
@@ -120,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"brand_mark": saved.BrandMark,
|
||||
"brand_logo_size": saved.BrandLogoSize,
|
||||
"brand_logo_fit": saved.BrandLogoFit,
|
||||
"brand_slogan_visible": saved.BrandSloganVisible,
|
||||
"footer_links": saved.FooterLinks,
|
||||
|
||||
"points_checkin_base": saved.PointsCheckinBase,
|
||||
@@ -160,6 +162,7 @@ func (req *updateSettingsRequest) hasAny() bool {
|
||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||
req.BrandSloganVisible != nil ||
|
||||
req.FooterLinks != nil ||
|
||||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||
@@ -540,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.BrandSloganVisible != nil {
|
||||
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.FooterLinks != nil {
|
||||
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
|
||||
@@ -56,7 +56,7 @@ func InitDB(dsn string) error {
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
|
||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
|
||||
@@ -362,12 +362,13 @@ type PostContentUnlock struct {
|
||||
|
||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||
type PostAttachment struct {
|
||||
ObjectID string `gorm:"size:64;index" json:"-"`
|
||||
ObjectID string `gorm:"size:96;index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
|
||||
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"`
|
||||
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
||||
@@ -666,13 +667,14 @@ const (
|
||||
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||
)
|
||||
|
||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
||||
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
||||
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
||||
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||
Width int `gorm:"not null;default:0" json:"width"`
|
||||
@@ -680,6 +682,20 @@ type Attachment struct {
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
|
||||
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
|
||||
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
|
||||
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
|
||||
type Blob struct {
|
||||
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
|
||||
Public bool `gorm:"primaryKey" json:"-"`
|
||||
RefCount int64 `gorm:"not null;default:0" json:"-"`
|
||||
Size int `gorm:"not null;default:0" json:"-"`
|
||||
MIME string `gorm:"size:128;not null;default:''" json:"-"`
|
||||
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
|
||||
CreatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
||||
type UserBoard struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -51,7 +51,7 @@ type EmailChallenge struct {
|
||||
|
||||
// Every remote object pins the immutable configuration revision that wrote it.
|
||||
type StoredObject struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
||||
ID string `gorm:"primaryKey;size:96" json:"id"`
|
||||
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||
Key string `gorm:"size:512" json:"-"`
|
||||
MIME string `gorm:"size:128" json:"-"`
|
||||
|
||||
@@ -107,10 +107,13 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
||||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||||
|
||||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||||
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
|
||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 邮箱统一小写归一化,避免大小写变体重复注册
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -130,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
||||
if count > 0 {
|
||||
return errors.New("用户名已被使用")
|
||||
}
|
||||
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
|
||||
if email != "" {
|
||||
var emailCount int64
|
||||
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if emailCount > 0 {
|
||||
return errors.New("该邮箱已被注册")
|
||||
}
|
||||
}
|
||||
var n int64
|
||||
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
@@ -681,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
||||
if nickCount > 0 {
|
||||
return nil, errors.New("该昵称已被使用")
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email != "" {
|
||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||
return nil, errors.New("邮箱格式不正确")
|
||||
}
|
||||
// 邮箱唯一性校验(排除自身)
|
||||
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
|
||||
var count int64
|
||||
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
|
||||
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("该邮箱已被使用")
|
||||
}
|
||||
|
||||
223
backend/service/blob_dedup_test.go
Normal file
223
backend/service/blob_dedup_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newBlobTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.Blob{}, &model.Attachment{}, &model.PostAttachment{},
|
||||
&model.User{}, &model.Post{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func webpFixtureBytes() []byte {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
|
||||
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
|
||||
var buf bytes.Buffer
|
||||
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
dir := t.TempDir()
|
||||
s := NewUploadService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
data := webpFixtureBytes()
|
||||
|
||||
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 1: %v", err)
|
||||
}
|
||||
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 2: %v", err)
|
||||
}
|
||||
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
|
||||
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
|
||||
}
|
||||
|
||||
var blob model.Blob
|
||||
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
|
||||
t.Fatalf("load blob: %v", err)
|
||||
}
|
||||
if blob.RefCount != 2 {
|
||||
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||
}
|
||||
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
|
||||
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
|
||||
t.Fatalf("shared physical file missing: %v", err)
|
||||
}
|
||||
|
||||
// 删一个引用:文件保留
|
||||
if err := s.DeleteAttachment(1, att1.ID); err != nil {
|
||||
t.Fatalf("DeleteAttachment 1: %v", err)
|
||||
}
|
||||
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
|
||||
if blob.RefCount != 1 {
|
||||
t.Fatalf("refcount want 1, got %d", blob.RefCount)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("blob file should survive while referenced: %v", err)
|
||||
}
|
||||
|
||||
// 最后一个引用删除:文件与 blob 行一起回收
|
||||
if err := s.DeleteAttachment(2, att2.ID); err != nil {
|
||||
t.Fatalf("DeleteAttachment 2: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("blob row should be gone, count=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
dir := t.TempDir()
|
||||
s := NewUploadService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
|
||||
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
|
||||
var in bytes.Buffer
|
||||
if err := jpeg.Encode(&in, src, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
|
||||
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 1: %v", err)
|
||||
}
|
||||
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 2: %v", err)
|
||||
}
|
||||
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
|
||||
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
|
||||
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
root := t.TempDir()
|
||||
s := NewPostFileService(db, root)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
data := []byte("paid attachment content v1")
|
||||
|
||||
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile 1: %v", err)
|
||||
}
|
||||
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile 2: %v", err)
|
||||
}
|
||||
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
|
||||
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
|
||||
}
|
||||
// 原始文件名仍各自保留
|
||||
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
|
||||
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
|
||||
}
|
||||
blobPath := s.FilePath(a1)
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("shared private blob missing: %v", err)
|
||||
}
|
||||
|
||||
var blob model.Blob
|
||||
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
|
||||
t.Fatalf("load private blob: %v", err)
|
||||
}
|
||||
if blob.RefCount != 2 {
|
||||
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||
}
|
||||
|
||||
if err := s.DeleteOwn(1, a1.ID); err != nil {
|
||||
t.Fatalf("DeleteOwn 1: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("private blob should survive with one ref: %v", err)
|
||||
}
|
||||
if err := s.DeleteOwn(2, a2.ID); err != nil {
|
||||
t.Fatalf("DeleteOwn 2: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
root := t.TempDir()
|
||||
img := NewUploadService(db, filepath.Join(root, "uploads"))
|
||||
pf := NewPostFileService(db, filepath.Join(root, "private"))
|
||||
if err := img.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure upload dir: %v", err)
|
||||
}
|
||||
if err := pf.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure private dir: %v", err)
|
||||
}
|
||||
data := webpFixtureBytes()
|
||||
|
||||
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile: %v", err)
|
||||
}
|
||||
if att.BlobHash != file.BlobHash {
|
||||
t.Fatalf("test premise: same bytes should produce same hash")
|
||||
}
|
||||
|
||||
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
|
||||
var pubs, privs int64
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
|
||||
if pubs != 1 || privs != 1 {
|
||||
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
|
||||
}
|
||||
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
|
||||
privatePath := pf.FilePath(file)
|
||||
if publicPath == privatePath {
|
||||
t.Fatal("public and private blob must not share a path")
|
||||
}
|
||||
if _, err := os.Stat(publicPath); err != nil {
|
||||
t.Fatalf("public blob missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(privatePath); err != nil {
|
||||
t.Fatalf("private blob missing: %v", err)
|
||||
}
|
||||
}
|
||||
132
backend/service/blob_store.go
Normal file
132
backend/service/blob_store.go
Normal 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
|
||||
}
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
|
||||
// 站点品牌与页脚(公开设置,缺省为空)
|
||||
const (
|
||||
SettingKeySiteWordmark = "site_wordmark"
|
||||
SettingKeySiteSlogan = "site_slogan"
|
||||
SettingKeySiteKeywords = "site_keywords"
|
||||
SettingKeyLogoLightURL = "logo_light_url"
|
||||
SettingKeyLogoDarkURL = "logo_dark_url"
|
||||
SettingKeyFaviconURL = "favicon_url"
|
||||
SettingKeyFooterLinks = "footer_links"
|
||||
SettingKeyBrandMark = "brand_mark"
|
||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||
SettingKeySiteWordmark = "site_wordmark"
|
||||
SettingKeySiteSlogan = "site_slogan"
|
||||
SettingKeySiteKeywords = "site_keywords"
|
||||
SettingKeyLogoLightURL = "logo_light_url"
|
||||
SettingKeyLogoDarkURL = "logo_dark_url"
|
||||
SettingKeyFaviconURL = "favicon_url"
|
||||
SettingKeyFooterLinks = "footer_links"
|
||||
SettingKeyBrandMark = "brand_mark"
|
||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||
SettingKeyBrandSloganVisible = "brand_slogan_visible"
|
||||
|
||||
BrandSlotLight = "logo_light"
|
||||
BrandSlotDark = "logo_dark"
|
||||
@@ -43,6 +44,8 @@ const (
|
||||
BrandLogoSize3x1 = "3x1"
|
||||
BrandLogoSize4x1 = "4x1"
|
||||
BrandLogoSize169 = "16x9"
|
||||
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
|
||||
BrandLogoSizeAuto = "auto"
|
||||
|
||||
BrandLogoFitCover = "cover"
|
||||
BrandLogoFitContain = "contain"
|
||||
@@ -114,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", BrandLogoSizeSQ:
|
||||
return BrandLogoSizeSQ, true
|
||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
|
||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
|
||||
return strings.TrimSpace(raw), true
|
||||
default:
|
||||
return "", false
|
||||
@@ -367,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
|
||||
out.BrandLogoFit = f
|
||||
}
|
||||
}
|
||||
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
out.BrandSloganVisible = sv == "true"
|
||||
} else {
|
||||
out.BrandSloganVisible = true
|
||||
}
|
||||
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -483,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
|
||||
return s.putValue(SettingKeyBrandLogoFit, v)
|
||||
}
|
||||
|
||||
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
|
||||
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
|
||||
if visible {
|
||||
return s.deleteKey(SettingKeyBrandSloganVisible)
|
||||
}
|
||||
return s.putValue(SettingKeyBrandSloganVisible, "false")
|
||||
}
|
||||
|
||||
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
||||
u, ok := NormalizeBrandURL(slot, url)
|
||||
if !ok {
|
||||
|
||||
@@ -64,6 +64,7 @@ type MediaLibraryItem struct {
|
||||
Height int `json:"height"`
|
||||
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
||||
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
||||
}
|
||||
@@ -91,13 +92,29 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metaByURL := make(map[string]attMeta, len(rows))
|
||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
||||
items := make([]MediaLibraryItem, 0, len(rows))
|
||||
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
|
||||
// 上传者/时间取最早一条,计数即该文件的引用次数
|
||||
type urlMeta struct {
|
||||
meta attMeta
|
||||
count int
|
||||
}
|
||||
agg := make(map[string]*urlMeta, len(rows))
|
||||
for _, r := range rows {
|
||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
||||
continue
|
||||
if a, ok := agg[r.URL]; ok {
|
||||
a.count++
|
||||
if r.CreatedAt.Before(a.meta.CreatedAt) {
|
||||
a.meta = r
|
||||
}
|
||||
} else {
|
||||
agg[r.URL] = &urlMeta{meta: r, count: 1}
|
||||
}
|
||||
}
|
||||
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
|
||||
items := make([]MediaLibraryItem, 0, len(agg))
|
||||
for url, a := range agg {
|
||||
r := a.meta
|
||||
if !strings.HasPrefix(url, "/api/media/") {
|
||||
continue // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||
}
|
||||
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||
continue // 未知 kind 不进媒体库
|
||||
@@ -109,10 +126,10 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||
ca := r.CreatedAt
|
||||
items = append(items, MediaLibraryItem{
|
||||
URL: r.URL, Category: catKey, CategoryName: catName,
|
||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
||||
URL: url, Category: catKey, CategoryName: catName,
|
||||
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
|
||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||
Uploader: uploader, UploadedAt: &ca,
|
||||
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,8 +169,9 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
mt := info.ModTime()
|
||||
item.UploadedAt = &mt
|
||||
}
|
||||
if m, ok := metaByURL[url]; ok {
|
||||
if a, ok := agg[url]; ok {
|
||||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||||
m := a.meta
|
||||
uploader := m.Nickname
|
||||
if uploader == "" {
|
||||
uploader = m.Username
|
||||
@@ -161,6 +179,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
ca := m.CreatedAt
|
||||
item.Uploader = uploader
|
||||
item.UploadedAt = &ca
|
||||
item.RefCount = a.count
|
||||
if m.Size > 0 {
|
||||
item.Size = int64(m.Size)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func newMediaLibraryService(t *testing.T) (*UploadService, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
@@ -315,6 +315,82 @@ func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
|
||||
|
||||
mkUser := func(username, nickname string) model.User {
|
||||
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var u model.User
|
||||
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
alice := mkUser("alice", "爱丽丝")
|
||||
bob := mkUser("bob", "阿Bob")
|
||||
|
||||
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
late := early.Add(2 * time.Hour)
|
||||
|
||||
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
|
||||
mkAtt := func(u model.User, url string, at time.Time) {
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: u.ID, Kind: model.AttachmentKindImage,
|
||||
URL: url, MIME: "image/webp",
|
||||
Size: 100, Width: 10, Height: 10, CreatedAt: at,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment %s: %v", url, err)
|
||||
}
|
||||
}
|
||||
mkAtt(alice, "/uploads/images/dup.webp", early)
|
||||
mkAtt(bob, "/uploads/images/dup.webp", late)
|
||||
mkAtt(bob, "/api/media/rdup", early)
|
||||
mkAtt(alice, "/api/media/rdup", late)
|
||||
|
||||
items, _, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
|
||||
countByURL := map[string]int{}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
countByURL[it.URL]++
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
|
||||
local, ok := byURL["/uploads/images/dup.webp"]
|
||||
if !ok {
|
||||
t.Fatal("dedup local file missing")
|
||||
}
|
||||
if countByURL["/uploads/images/dup.webp"] != 1 {
|
||||
t.Fatal("dedup local file should appear exactly once")
|
||||
}
|
||||
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
|
||||
t.Fatalf("local dedup meta wrong: %+v", local)
|
||||
}
|
||||
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
|
||||
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
|
||||
}
|
||||
|
||||
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
|
||||
remote, ok := byURL["/api/media/rdup"]
|
||||
if !ok {
|
||||
t.Fatal("dedup remote object missing")
|
||||
}
|
||||
if countByURL["/api/media/rdup"] != 1 {
|
||||
t.Fatal("dedup remote object should appear exactly once")
|
||||
}
|
||||
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
|
||||
t.Fatalf("remote dedup meta wrong: %+v", remote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaThumb(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ import (
|
||||
|
||||
type mailPayload struct{ To, Subject, Body string }
|
||||
|
||||
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
|
||||
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
|
||||
|
||||
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||
password, e := o.open(c.Password, "mail:password")
|
||||
if e != nil {
|
||||
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
if purpose == "register" {
|
||||
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
|
||||
var n int64
|
||||
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if n > 0 {
|
||||
return 0, ErrEmailRegistered
|
||||
}
|
||||
}
|
||||
code := randomCode()
|
||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
|
||||
func blobObjectID(hash string, public bool) string {
|
||||
if public {
|
||||
return "pub_" + hash
|
||||
}
|
||||
return "prv_" + hash
|
||||
}
|
||||
|
||||
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
|
||||
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
|
||||
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
|
||||
v, version, e := o.read(o.db, "storage")
|
||||
if e != nil {
|
||||
return "", errors.New("读取存储配置失败")
|
||||
}
|
||||
c := *v.(*StorageConfig)
|
||||
if c.Backend == "local" {
|
||||
return "", nil
|
||||
}
|
||||
id := blobObjectID(hash, public)
|
||||
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
|
||||
var existing model.StoredObject
|
||||
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
|
||||
return id, nil
|
||||
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return "", e
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
stat, e := f.Stat()
|
||||
if e != nil {
|
||||
_ = f.Close()
|
||||
return "", e
|
||||
}
|
||||
key := c.Prefix + "objects/" + id
|
||||
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||
_ = f.Close()
|
||||
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||
}
|
||||
_ = f.Close()
|
||||
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||
if e = o.db.Create(&obj).Error; e != nil {
|
||||
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
|
||||
return id, nil
|
||||
}
|
||||
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||
return "", errors.New("保存文件记录失败")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||
var obj model.StoredObject
|
||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||
|
||||
@@ -2,10 +2,12 @@ package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unicode/utf8"
|
||||
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
|
||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
|
||||
hasher := sha256.New()
|
||||
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
final := absPath(s.dir, hash)
|
||||
var objectID string
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(full, att.MIME, false)
|
||||
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, e
|
||||
}
|
||||
att.ObjectID = id
|
||||
objectID = id
|
||||
if id != "" {
|
||||
_ = os.Remove(full)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
if objectID == "" {
|
||||
if e := placeLocalBlob(full, final); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: hash,
|
||||
BlobHash: hash,
|
||||
ObjectID: objectID,
|
||||
MIME: mimeType,
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Create(att).Error
|
||||
}); err != nil {
|
||||
s.releasePostBlobBestEffort(hash)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
if att.UserID != userID {
|
||||
return ErrAttachmentForbidden
|
||||
}
|
||||
path := absPath(s.dir, att.StoredName)
|
||||
if err := s.db.Delete(&att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if att.BlobHash != "" {
|
||||
return s.releasePostBlob(&att)
|
||||
}
|
||||
if s.ops != nil && att.ObjectID != "" {
|
||||
return s.ops.RemoveObject(att.ObjectID)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
_ = os.Remove(absPath(s.dir, att.StoredName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
|
||||
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
|
||||
if err != nil || !last {
|
||||
return err
|
||||
}
|
||||
return s.removePrivateBlobObject(b)
|
||||
}
|
||||
|
||||
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
|
||||
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
|
||||
b, last, err := releaseBlobRef(s.db, hash, false)
|
||||
if err != nil {
|
||||
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||
return
|
||||
}
|
||||
if !last {
|
||||
return
|
||||
}
|
||||
if err := s.removePrivateBlobObject(b); err != nil {
|
||||
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
|
||||
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
|
||||
if b.ObjectID != "" && s.ops != nil {
|
||||
return s.ops.RemoveObject(b.ObjectID)
|
||||
}
|
||||
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ type PublicSiteSettings struct {
|
||||
BrandMark string `json:"brand_mark"`
|
||||
BrandLogoSize string `json:"brand_logo_size"`
|
||||
BrandLogoFit string `json:"brand_logo_fit"`
|
||||
BrandSloganVisible bool `json:"brand_slogan_visible"`
|
||||
FooterLinks []FooterLink `json:"footer_links"`
|
||||
|
||||
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -25,7 +25,7 @@ func newUploadTestService(t *testing.T) (*UploadService, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user