package service import ( "crypto/rand" "encoding/hex" "errors" "mime" "os" "path/filepath" "strings" "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) const ( FileMaxBytes = 20 << 20 // 20 MiB MaxPostAttachments = 10 ) var ( ErrAttachmentNotFound = errors.New("附件不存在") ErrAttachmentForbidden = errors.New("无权操作此附件") ErrTooManyAttachments = errors.New("附件数量超过上限") ) // PostFileService 帖子文件附件(私有目录 + API 下载) type PostFileService struct { db *gorm.DB dir string // data/private/files } func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService { return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")} } func (s *PostFileService) EnsureDir() error { return os.MkdirAll(s.dir, 0o755) } func absPath(dir, stored string) string { return filepath.Join(dir, stored) } // SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定) func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) { if len(data) == 0 { return nil, errors.New("文件为空") } if len(data) > FileMaxBytes { return nil, errors.New("附件不能超过 20MB") } if pricePoints < 0 { pricePoints = 0 } if pricePoints > 100000 { return nil, errors.New("积分定价过高") } name := sanitizeFilename(originalName) if name == "" { name = "file" } 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 >= MaxPostAttachments { return nil, ErrTooManyAttachments } ext := filepath.Ext(name) if utf8.RuneCountInString(ext) > 16 { ext = "" } raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { return nil, err } stored := hex.EncodeToString(raw) + ext full := absPath(s.dir, stored) if err := os.WriteFile(full, data, 0o600); err != nil { return nil, err } mimeType := mime.TypeByExtension(ext) if mimeType == "" { mimeType = "application/octet-stream" } att := &model.PostAttachment{ PostID: 0, UserID: userID, Name: name, StoredName: stored, MIME: mimeType, Size: len(data), PricePoints: pricePoints, } if err := s.db.Create(att).Error; err != nil { _ = os.Remove(full) return nil, err } return att, nil } func sanitizeFilename(name string) string { name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) name = strings.TrimSpace(name) name = strings.Map(func(r rune) rune { switch r { case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|': return '_' default: return r } }, name) if utf8.RuneCountInString(name) > 200 { runes := []rune(name) name = string(runes[:200]) } return name } // BindToPost 将草稿附件绑定到帖子(仅本人、未绑定) func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error { if len(ids) == 0 { return nil } if len(ids) > MaxPostAttachments { 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 { if len(ids) > MaxPostAttachments { 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("部分附件无效或无权使用") } } // 解绑本帖旧附件(软删物理文件可选:P1 仅解绑) 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 } _ = os.Remove(path) return nil } // OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分 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 }) }