diff --git a/backend/handler/operations.go b/backend/handler/operations.go index 42a01f3..5c72b2d 100644 --- a/backend/handler/operations.go +++ b/backend/handler/operations.go @@ -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 } diff --git a/backend/handler/setting.go b/backend/handler/setting.go index df1da53..1cd645d 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -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) { diff --git a/backend/model/db.go b/backend/model/db.go index 8852b65..d4c5428 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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{}, diff --git a/backend/model/models.go b/backend/model/models.go index cee0098..c623704 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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/)被越权读取。 +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"` diff --git a/backend/model/operations.go b/backend/model/operations.go index dd12794..2d01dcc 100644 --- a/backend/model/operations.go +++ b/backend/model/operations.go @@ -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:"-"` diff --git a/backend/service/auth.go b/backend/service/auth.go index 22b3e42..00b428c 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -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("该邮箱已被使用") } diff --git a/backend/service/blob_dedup_test.go b/backend/service/blob_dedup_test.go new file mode 100644 index 0000000..f04affb --- /dev/null +++ b/backend/service/blob_dedup_test.go @@ -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) + } +} diff --git a/backend/service/blob_store.go b/backend/service/blob_store.go new file mode 100644 index 0000000..08c88ee --- /dev/null +++ b/backend/service/blob_store.go @@ -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 +} diff --git a/backend/service/brand.go b/backend/service/brand.go index 553567d..c929afb 100644 --- a/backend/service/brand.go +++ b/backend/service/brand.go @@ -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 { diff --git a/backend/service/media_library.go b/backend/service/media_library.go index a88a4a7..e7d955f 100644 --- a/backend/service/media_library.go +++ b/backend/service/media_library.go @@ -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) } diff --git a/backend/service/media_library_test.go b/backend/service/media_library_test.go index df1cb16..c3d2567 100644 --- a/backend/service/media_library_test.go +++ b/backend/service/media_library_test.go @@ -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) diff --git a/backend/service/operations_mail.go b/backend/service/operations_mail.go index 2aadb7c..cccfc91 100644 --- a/backend/service/operations_mail.go +++ b/backend/service/operations_mail.go @@ -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 { diff --git a/backend/service/operations_storage.go b/backend/service/operations_storage.go index 5a255f2..d95a069 100644 --- a/backend/service/operations_storage.go +++ b/backend/service/operations_storage.go @@ -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 { diff --git a/backend/service/post_file.go b/backend/service/post_file.go index f922720..5567d72 100644 --- a/backend/service/post_file.go +++ b/backend/service/post_file.go @@ -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 } diff --git a/backend/service/setting.go b/backend/service/setting.go index 73cc2ee..8ed070d 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -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"` // 积分增长规则(经济规则公开透明,签到卡等可直出) diff --git a/backend/service/upload.go b/backend/service/upload.go index da40f61..d1e6f51 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -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 } diff --git a/backend/service/upload_webp_test.go b/backend/service/upload_webp_test.go index 71e8aee..21046e8 100644 --- a/backend/service/upload_webp_test.go +++ b/backend/service/upload_webp_test.go @@ -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() diff --git a/frontend/app/admin/analytics/details/page.tsx b/frontend/app/admin/analytics/details/page.tsx index 56012a7..253dbf3 100644 --- a/frontend/app/admin/analytics/details/page.tsx +++ b/frontend/app/admin/analytics/details/page.tsx @@ -138,7 +138,7 @@ function DetailsInner() {
{ setPath(v); }} + onChange={(v) => { setPath(v); resetPage(); }} onSubmit={resetPage} onClear={() => { setPath(""); resetPage(); }} placeholder="路径模糊匹配,回车应用" @@ -148,7 +148,7 @@ function DetailsInner() {
{ setIp(v); resetPage(); }} onSubmit={resetPage} onClear={() => { setIp(""); resetPage(); }} placeholder="按 IP 精确筛选" @@ -158,7 +158,7 @@ function DetailsInner() {
{ setBot(v); resetPage(); }} onSubmit={resetPage} onClear={() => { setBot(""); resetPage(); }} placeholder="按爬虫名精确筛选" diff --git a/frontend/app/admin/media/MediaLibrary.tsx b/frontend/app/admin/media/MediaLibrary.tsx index c99552d..55799c9 100644 --- a/frontend/app/admin/media/MediaLibrary.tsx +++ b/frontend/app/admin/media/MediaLibrary.tsx @@ -179,7 +179,8 @@ export default function MediaLibrary({ setActive(null)} - title={active?.name ?? ""} + title={{active?.name ?? ""}} + ariaLabel={active?.name ?? ""} description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined} maxWidthClass="max-w-2xl" dismissOnOverlay @@ -218,6 +219,14 @@ export default function MediaLibrary({ ["格式", active.mime], ["上传者", active.uploader || "—"], ["时间", formatTime(active.uploaded_at)], + ...(active.ref_count && active.ref_count > 1 + ? [ + [ + "引用", + `${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`, + ], + ] + : []), ].map(([k, v]) => (
{k}
diff --git a/frontend/app/admin/settings/BasicIdentityPanel.tsx b/frontend/app/admin/settings/BasicIdentityPanel.tsx index ce8b26e..a7a882d 100644 --- a/frontend/app/admin/settings/BasicIdentityPanel.tsx +++ b/frontend/app/admin/settings/BasicIdentityPanel.tsx @@ -2,7 +2,7 @@ import { X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin"; +import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin"; import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api"; import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand"; import { toast } from "@/lib/toast"; @@ -25,6 +25,7 @@ export default function BasicIdentityPanel({ }, []); const [wordmark, setWordmark] = useState(initial.site_wordmark); const [slogan, setSlogan] = useState(initial.site_slogan); + const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible); const [desc, setDesc] = useState(initial.site_description); const [keywords, setKeywords] = useState(initial.site_keywords); const [kwInput, setKwInput] = useState(""); @@ -32,6 +33,7 @@ export default function BasicIdentityPanel({ name: initial.site_name, wordmark: initial.site_wordmark, slogan: initial.site_slogan, + sloganVisible: initial.brand_slogan_visible, desc: initial.site_description, keywords: initial.site_keywords, }); @@ -44,6 +46,7 @@ export default function BasicIdentityPanel({ name.trim() !== saved.name || wordmark.trim() !== saved.wordmark || slogan.trim() !== saved.slogan || + sloganVisible !== saved.sloganVisible || desc.trim() !== saved.desc || JSON.stringify(keywords) !== JSON.stringify(saved.keywords); @@ -85,6 +88,7 @@ export default function BasicIdentityPanel({ setName(saved.name); setWordmark(saved.wordmark); setSlogan(saved.slogan); + setSloganVisible(saved.sloganVisible); setDesc(saved.desc); setKeywords([...saved.keywords]); setKwInput(""); @@ -98,6 +102,7 @@ export default function BasicIdentityPanel({ name: name.trim(), wordmark: wordmark.trim(), slogan: slogan.trim(), + sloganVisible, desc: desc.trim(), keywords: [...keywords], }; @@ -115,6 +120,7 @@ export default function BasicIdentityPanel({ site_name: snap.name, site_wordmark: snap.wordmark, site_slogan: snap.slogan, + brand_slogan_visible: snap.sloganVisible, site_description: snap.desc, site_keywords: snap.keywords, }); @@ -123,12 +129,14 @@ export default function BasicIdentityPanel({ name: res.site_name, wordmark: res.site_wordmark, slogan: res.site_slogan, + sloganVisible: res.brand_slogan_visible, desc: res.site_description, keywords: res.site_keywords, }); setName((v) => (v.trim() === snap.name ? res.site_name : v)); setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v)); setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v)); + setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v)); setDesc((v) => (v.trim() === snap.desc ? res.site_description : v)); setKeywords((prev) => JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev, @@ -199,18 +207,49 @@ export default function BasicIdentityPanel({ />
- { - setSlogan(v); - clearSaveError(); - }} - placeholder="技术分享与讨论" - /> +
+
+ + + {slogan.length}/{BRAND_LIMITS.slogan} + +
+
+ { + setSloganVisible((v) => !v); + clearSaveError(); + }} + label="页眉显示标语" + /> + + 页眉显示标语 + +
+
+ { + setSlogan(e.target.value); + clearSaveError(); + }} + placeholder="技术分享与讨论" + /> +
+

浏览器标题预览 · {preview}

diff --git a/frontend/app/admin/settings/BrandSeoPanel.tsx b/frontend/app/admin/settings/BrandSeoPanel.tsx index c31f849..28526be 100644 --- a/frontend/app/admin/settings/BrandSeoPanel.tsx +++ b/frontend/app/admin/settings/BrandSeoPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, ImagePlus, Images, Link2, X } from "lucide-react"; +import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react"; import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { AdminSegmented, @@ -11,8 +11,6 @@ import BrandCropModal from "@/components/BrandCropModal"; import BrandLockup from "@/components/BrandLockup"; import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; import { - BRAND_LOGO_FITS, - BRAND_LOGO_SIZES, brandAccept, brandAliasPath, brandFileOk, @@ -313,6 +311,17 @@ export default function BrandSeoPanel({ > 库 + {preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? ( + + ) : null}
{mark !== "text" ? ( - <> -
- 比例 -
- {BRAND_LOGO_SIZES.map((item) => { - const on = logoSize === item.id; - return ( - - ); - })} -
-
-
- 铺法 - { - setLogoFit(v); - if (saveError) setSaveError(""); - }} - options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} - /> -
- +

+ 画幅与铺法在上传/调整图片的裁剪弹窗中设置 +

) : null}
@@ -471,6 +443,16 @@ export default function BrandSeoPanel({ slot={cropSlot} seedFile={cropSeedFile} seedUrl={cropSeedUrl} + logoSize={logoSize} + logoFit={logoFit} + onSizeChange={(s) => { + setLogoSize(s); + if (saveError) setSaveError(""); + }} + onFitChange={(f) => { + setLogoFit(f); + if (saveError) setSaveError(""); + }} onClose={closeCrop} onSeedConsumed={() => { setCropSeedFile(null); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5c8e8dd..a26cdc4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5150,6 +5150,15 @@ a.j13-mention-token:hover { flex-shrink: 0; overflow: hidden; } + .j13-brand-slot[data-size="auto"] { + width: auto; + max-width: 176px; + } + .j13-brand-slot[data-size="auto"] > .j13-brand-logo { + width: auto; + max-width: 176px; + object-fit: contain; + } .j13-brand-slot[data-frame="1"] { border-radius: 8px; background: color-mix(in srgb, var(--panel) 70%, transparent); diff --git a/frontend/app/register/RegisterClient.tsx b/frontend/app/register/RegisterClient.tsx index 73b1764..a6f4961 100644 --- a/frontend/app/register/RegisterClient.tsx +++ b/frontend/app/register/RegisterClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { UserPlus } from "lucide-react"; @@ -44,8 +44,33 @@ export default function RegisterClient({ const [notice, setNotice] = useState(registerNotice); const [sending, setSending] = useState(false); const [sentMessage, setSentMessage] = useState(""); + // 重发倒计时(秒);>0 期间按钮锁定不可点 + const [countdown, setCountdown] = useState(0); + const countdownTimer = useRef | null>(null); const verifyEmail = initialVerifyEmail; + // 卸载时清理倒计时,避免泄漏 + useEffect(() => { + return () => { + if (countdownTimer.current) clearInterval(countdownTimer.current); + }; + }, []); + + const startCountdown = () => { + if (countdownTimer.current) clearInterval(countdownTimer.current); + setCountdown(60); + countdownTimer.current = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + if (countdownTimer.current) clearInterval(countdownTimer.current); + countdownTimer.current = null; + return 0; + } + return c - 1; + }); + }, 1000); + }; + // 已登录用户不必再注册,直接回首页 useEffect(() => { if (!hasAuthCookieHint()) return; @@ -166,8 +191,9 @@ export default function RegisterClient({
{sentMessage && ( diff --git a/frontend/components/BrandCropModal.tsx b/frontend/components/BrandCropModal.tsx index 1f244f6..70d5e7f 100644 --- a/frontend/components/BrandCropModal.tsx +++ b/frontend/components/BrandCropModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import Cropper, { type Area } from "react-easy-crop"; import "react-easy-crop/react-easy-crop.css"; import { @@ -10,29 +10,25 @@ import { apiMyMedia, apiUploadBrand, type MediaAttachment, } from "@/lib/api"; -import { getCroppedBrandWebp } from "@/lib/cropImage"; +import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage"; import Modal from "@/components/Modal"; -import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand"; +import BrandLockup from "@/components/BrandLockup"; +import { AdminSegmented } from "@/components/admin"; +import { + BRAND_LOGO_FITS, + BRAND_LOGO_SIZES, + BRAND_LOGO_SIZE_ASPECT, + brandFileOk, + brandLogoBox, + suggestLogoSize, + type BrandLogoFit, + type BrandLogoSize, + type BrandSlot, +} from "@/lib/brand"; // 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) const SOURCE_MAX_BYTES = 15 * 1024 * 1024; -// 品牌图槽位到默认裁剪比例的映射 -const SLOT_ASPECT: Record = { - logo_light: 1, // 方标 1:1 - logo_dark: 1, // 方标 1:1 - favicon: 1, // Favicon 1:1 -}; - -// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应) -const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [ - { id: "sq", label: "1:1", aspect: 1 }, - { id: "2x1", label: "2:1", aspect: 2 }, - { id: "3x1", label: "3:1", aspect: 3 }, - { id: "4x1", label: "4:1", aspect: 4 }, - { id: "16x9", label: "16:9", aspect: 16 / 9 }, -]; - interface BrandCropModalProps { open: boolean; slot: BrandSlot; @@ -45,10 +41,19 @@ interface BrandCropModalProps { seedUrl?: string; /** 已消费 seedFile / seedUrl */ onSeedConsumed?: () => void; + /** 当前显示画幅(与工具栏同源,受控) */ + logoSize?: BrandLogoSize; + /** 当前铺法(与工具栏同源,受控) */ + logoFit?: BrandLogoFit; + /** 画幅变更回调(同步到父组件工具栏) */ + onSizeChange?: (size: BrandLogoSize) => void; + /** 铺法变更回调(同步到父组件工具栏) */ + onFitChange?: (fit: BrandLogoFit) => void; } export default function BrandCropModal({ open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed, + logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange, }: BrandCropModalProps) { const [media, setMedia] = useState([]); const [imageSrc, setImageSrc] = useState(null); @@ -56,11 +61,18 @@ export default function BrandCropModal({ const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [pixelCrop, setPixelCrop] = useState(null); - const [aspect, setAspect] = useState(SLOT_ASPECT[slot] || 1); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); + // 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅) + const detectedSrcRef = useRef(null); + + // Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪 + const isFavicon = slot === "favicon"; + const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize; + const isAuto = effectiveSize === "auto"; + const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize]; // 打开时拉取媒体库 useEffect(() => { @@ -71,6 +83,23 @@ export default function BrandCropModal({ .catch(() => setMedia([])); }, [open]); + // 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次 + useEffect(() => { + if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return; + detectedSrcRef.current = imageSrc; + const img = new Image(); + img.onload = () => { + onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight)); + }; + img.src = imageSrc; + }, [imageSrc, isFavicon, onSizeChange]); + + // 画幅变化时重置构图位置(换画幅=重新构图;缩放保留) + useEffect(() => { + if (!open) return; + setCrop({ x: 0, y: 0 }); + }, [effectiveSize, open]); + // 关闭时复位并释放本地 blob URL useEffect(() => { if (!open) { @@ -82,10 +111,10 @@ export default function BrandCropModal({ setCrop({ x: 0, y: 0 }); setZoom(1); setPixelCrop(null); - setAspect(SLOT_ASPECT[slot] || 1); setError(""); setBusy(false); setDragOver(false); + detectedSrcRef.current = null; } }, [open, slot]); @@ -178,12 +207,15 @@ export default function BrandCropModal({ }; const confirmUpload = async () => { - if (!imageSrc || !pixelCrop || busy) return; + if (!imageSrc || busy) return; + if (!isAuto && !pixelCrop) return; setBusy(true); setError(""); try { - // 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB) - const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect); + // 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出 + const blob = isAuto + ? await getFullBrandWebp(imageSrc) + : await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!); const file = new File([blob], "brand.webp", { type: "image/webp" }); const url = await apiUploadBrand(slot, file); onCropped(url); @@ -292,64 +324,166 @@ export default function BrandCropModal({ ) : ( <> - {/* 比例选择 */} -
- 比例 - {ASPECT_OPTIONS.map((opt) => ( - + ); + })} +
+ + )} + + {/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */} + {isAuto ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 整图预览 + - {opt.label} - - ))} -
+ 整图使用,不裁剪 + + + ) : ( + <> +
+ +
- {/* 裁剪区 */} + {/* 缩放 + 重置 */} +
+ + setZoom(Number(e.target.value))} + className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer" + style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }} + aria-label="缩放" + /> + + +
+ + )} + + {/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */} + {!isFavicon && ( + isAuto ? ( +

+ 原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。 +

+ ) : ( +
+
+ 铺法 + onFitChange?.(v)} + options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} + /> +
+

+ {logoFit === "contain" + ? "适应:完整显示图片,比例不匹配时留空(推荐)" + : logoFit === "cover" + ? "填充:裁切填满画幅" + : logoFit === "repeat" + ? "平铺:重复铺满画幅" + : "拉伸:变形填满画幅"} +

+
+ ) + )} + + {/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
-
- {/* 缩放 */} -
- - setZoom(Number(e.target.value))} - className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer" - style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }} - aria-label="缩放" - /> - -
- {/* 输出说明 + 操作 */}
- 输出 WebP,高度 ≤ 2048px + {isAuto + ? "输出 WebP · 高度 40px,宽度随原图比例" + : `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}