Files
jiang13-bbs/backend/service/post_file.go

359 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }