管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
8.5 KiB
Go
335 lines
8.5 KiB
Go
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 {
|
||
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"
|
||
|
||
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 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
|
||
}
|
||
_ = 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
|
||
})
|
||
}
|