package service import ( "crypto/rand" "encoding/hex" "errors" "fmt" "io" "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 字节以区分「刚好上限」与「超限」 written, copyErr := io.Copy(f, 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 } att := &model.PostAttachment{ PostID: 0, UserID: userID, Name: name, StoredName: stored, MIME: mimeType, Size: int(written), PricePoints: pricePoints, } if s.ops != nil { id, e := s.ops.StoreFile(full, att.MIME, false) if e != nil { _ = os.Remove(full) return nil, e } att.ObjectID = id if id != "" { _ = os.Remove(full) } } if err := s.db.Create(att).Error; err != nil { _ = os.Remove(full) 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 } path := absPath(s.dir, att.StoredName) if err := s.db.Delete(&att).Error; err != nil { return err } if s.ops != nil && att.ObjectID != "" { return s.ops.RemoveObject(att.ObjectID) } _ = os.Remove(path) 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 }