新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
416 lines
11 KiB
Go
416 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"unicode/utf8"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 附件默认体积/个数上限(实际限额读 SettingService;此处供校验与测试引用)
|
||
const (
|
||
FileMaxBytes = 20 << 20 // 默认 20 MiB
|
||
MaxPostAttachments = 10
|
||
)
|
||
|
||
var (
|
||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||
ErrAttachmentForbidden = errors.New("无权操作此附件")
|
||
ErrTooManyAttachments = errors.New("附件数量超过上限")
|
||
ErrAttachmentExtDenied = errors.New("不支持该附件格式")
|
||
ErrAttachmentTooLarge = errors.New("附件过大")
|
||
ErrAttachmentEmpty = errors.New("文件为空")
|
||
)
|
||
|
||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||
type PostFileService struct {
|
||
ops *Operations
|
||
db *gorm.DB
|
||
dir string // data/private/files
|
||
setting *SettingService
|
||
}
|
||
|
||
func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
|
||
return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
|
||
}
|
||
|
||
// WithSetting 注入站点设置(附件限额/扩展名)
|
||
func (s *PostFileService) WithSetting(setting *SettingService) *PostFileService {
|
||
s.setting = setting
|
||
return s
|
||
}
|
||
|
||
func (s *PostFileService) EnsureDir() error {
|
||
return os.MkdirAll(s.dir, 0o755)
|
||
}
|
||
|
||
func absPath(dir, stored string) string {
|
||
return filepath.Join(dir, stored)
|
||
}
|
||
|
||
func (s *PostFileService) maxBytes() int64 {
|
||
if s.setting == nil {
|
||
return FileMaxBytes
|
||
}
|
||
n, err := s.setting.AttachmentMaxBytes()
|
||
if err != nil || n < 1 {
|
||
return FileMaxBytes
|
||
}
|
||
return n
|
||
}
|
||
|
||
func (s *PostFileService) maxCount() int {
|
||
if s.setting == nil {
|
||
return MaxPostAttachments
|
||
}
|
||
n, err := s.setting.AttachmentMaxCount()
|
||
if err != nil || n < 1 {
|
||
return MaxPostAttachments
|
||
}
|
||
return n
|
||
}
|
||
|
||
func (s *PostFileService) tooLargeErr() error {
|
||
mb := int(s.maxBytes() >> 20)
|
||
if mb < 1 {
|
||
mb = 1
|
||
}
|
||
return fmt.Errorf("%w:不能超过 %dMB", ErrAttachmentTooLarge, mb)
|
||
}
|
||
|
||
// SaveDraftFile 流式落盘附件(先挂 post_id=0,发帖时绑定)。整文件不进内存。
|
||
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io.Reader, pricePoints int) (*model.PostAttachment, error) {
|
||
if src == nil {
|
||
return nil, ErrAttachmentEmpty
|
||
}
|
||
if pricePoints < 0 {
|
||
pricePoints = 0
|
||
}
|
||
if pricePoints > 100000 {
|
||
return nil, errors.New("积分定价过高")
|
||
}
|
||
name := sanitizeFilename(originalName)
|
||
if name == "" {
|
||
name = "file"
|
||
}
|
||
|
||
ext := ExtOfFilename(name)
|
||
if utf8.RuneCountInString(ext) > 16 {
|
||
ext = ""
|
||
}
|
||
if s.setting != nil {
|
||
ok, err := s.setting.AttachmentExtAllowed(ext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
return nil, ErrAttachmentExtDenied
|
||
}
|
||
}
|
||
|
||
maxC := s.maxCount()
|
||
var orphan int64
|
||
if err := s.db.Model(&model.PostAttachment{}).
|
||
Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if orphan >= int64(maxC) {
|
||
return nil, ErrTooManyAttachments
|
||
}
|
||
|
||
maxB := s.maxBytes()
|
||
raw := make([]byte, 16)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return nil, err
|
||
}
|
||
stored := hex.EncodeToString(raw) + ext
|
||
full := absPath(s.dir, stored)
|
||
tmp := full + ".partial"
|
||
if s.ops != nil {
|
||
release, e := s.ops.BeginTemporary(tmp)
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer release()
|
||
}
|
||
|
||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 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)
|
||
return nil, copyErr
|
||
}
|
||
if written == 0 {
|
||
_ = os.Remove(tmp)
|
||
return nil, ErrAttachmentEmpty
|
||
}
|
||
if written > maxB {
|
||
_ = os.Remove(tmp)
|
||
return nil, s.tooLargeErr()
|
||
}
|
||
|
||
mimeType := ResolveAttachmentMIMEFile(ext, tmp)
|
||
if err := os.Rename(tmp, full); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
|
||
// 内容寻址:私有 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.StoreFileHashed(full, mimeType, false, hash)
|
||
if e != nil {
|
||
_ = os.Remove(full)
|
||
return nil, e
|
||
}
|
||
objectID = id
|
||
if id != "" {
|
||
_ = 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
|
||
}
|
||
|
||
// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
|
||
func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
maxC := s.maxCount()
|
||
if len(ids) > maxC {
|
||
return ErrTooManyAttachments
|
||
}
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
var atts []model.PostAttachment
|
||
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(atts) != len(ids) {
|
||
return errors.New("部分附件无效或无权使用")
|
||
}
|
||
return tx.Model(&model.PostAttachment{}).
|
||
Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).
|
||
Update("post_id", postID).Error
|
||
})
|
||
}
|
||
|
||
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
|
||
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
|
||
maxC := s.maxCount()
|
||
if len(ids) > maxC {
|
||
return ErrTooManyAttachments
|
||
}
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
var keep []model.PostAttachment
|
||
if len(ids) > 0 {
|
||
if err := tx.Where(
|
||
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
|
||
ids, userID, postID,
|
||
).Find(&keep).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(keep) != len(ids) {
|
||
return errors.New("部分附件无效或无权使用")
|
||
}
|
||
}
|
||
if err := tx.Model(&model.PostAttachment{}).
|
||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||
Update("post_id", 0).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
return tx.Model(&model.PostAttachment{}).
|
||
Where("id IN ? AND user_id = ?", ids, userID).
|
||
Update("post_id", postID).Error
|
||
})
|
||
}
|
||
|
||
// ListByPost 帖子附件列表
|
||
func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) {
|
||
var list []model.PostAttachment
|
||
err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error
|
||
return list, err
|
||
}
|
||
|
||
// UpdatePrice 更新附件积分定价(作者)
|
||
func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error {
|
||
if price < 0 {
|
||
price = 0
|
||
}
|
||
res := s.db.Model(&model.PostAttachment{}).
|
||
Where("id = ? AND user_id = ?", attID, userID).
|
||
Update("price_points", price)
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return ErrAttachmentNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteOwn 删除本人未绑定或本帖附件
|
||
func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||
var att model.PostAttachment
|
||
if err := s.db.First(&att, attID).Error; err != nil {
|
||
return ErrAttachmentNotFound
|
||
}
|
||
if att.UserID != userID {
|
||
return ErrAttachmentForbidden
|
||
}
|
||
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(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
|
||
}
|
||
|
||
// Get 按 ID 取附件
|
||
func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
|
||
var att model.PostAttachment
|
||
if err := s.db.First(&att, attID).Error; err != nil {
|
||
return nil, ErrAttachmentNotFound
|
||
}
|
||
return &att, nil
|
||
}
|
||
|
||
func (s *PostFileService) FilePath(att *model.PostAttachment) string {
|
||
return absPath(s.dir, att.StoredName)
|
||
}
|
||
|
||
func (s *PostFileService) IncDownload(attID uint) {
|
||
s.db.Model(&model.PostAttachment{}).Where("id = ?", attID).
|
||
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||
}
|
||
|
||
// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次
|
||
func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error {
|
||
if att.PricePoints <= 0 {
|
||
return nil
|
||
}
|
||
if att.UserID == userID {
|
||
return nil
|
||
}
|
||
var n int64
|
||
if err := s.db.Model(&model.PostAttachmentUnlock{}).
|
||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||
Count(&n).Error; err != nil {
|
||
return err
|
||
}
|
||
if n > 0 {
|
||
return nil
|
||
}
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
var again int64
|
||
if err := tx.Model(&model.PostAttachmentUnlock{}).
|
||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||
Count(&again).Error; err != nil {
|
||
return err
|
||
}
|
||
if again > 0 {
|
||
return nil
|
||
}
|
||
if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
|
||
return err
|
||
}
|
||
if att.UserID > 0 && att.UserID != userID {
|
||
if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return tx.Create(&model.PostAttachmentUnlock{
|
||
AttachmentID: att.ID,
|
||
UserID: userID,
|
||
Points: att.PricePoints,
|
||
}).Error
|
||
})
|
||
}
|
||
|
||
func (s *PostFileService) WithOperations(o *Operations) { s.ops = o }
|