feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,37 +4,48 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 兼容旧引用:默认体积/个数(实际限额读 SettingService)
|
||||
const (
|
||||
FileMaxBytes = 20 << 20 // 20 MiB
|
||||
FileMaxBytes = 20 << 20 // 默认 20 MiB(缺省配置)
|
||||
MaxPostAttachments = 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentForbidden = errors.New("无权操作此附件")
|
||||
ErrTooManyAttachments = 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
|
||||
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)
|
||||
}
|
||||
@@ -43,13 +54,40 @@ 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("文件为空")
|
||||
func (s *PostFileService) maxBytes() int64 {
|
||||
if s.setting == nil {
|
||||
return FileMaxBytes
|
||||
}
|
||||
if len(data) > FileMaxBytes {
|
||||
return nil, errors.New("附件不能超过 20MB")
|
||||
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
|
||||
@@ -62,32 +100,63 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
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 >= MaxPostAttachments {
|
||||
if orphan >= int64(maxC) {
|
||||
return nil, ErrTooManyAttachments
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
if utf8.RuneCountInString(ext) > 16 {
|
||||
ext = ""
|
||||
}
|
||||
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)
|
||||
if err := os.WriteFile(full, data, 0o600); err != nil {
|
||||
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 := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
mimeType := ResolveAttachmentMIMEFile(ext, tmp)
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
@@ -96,7 +165,7 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: len(data),
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
@@ -106,30 +175,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
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 {
|
||||
maxC := s.maxCount()
|
||||
if len(ids) > maxC {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -148,7 +200,8 @@ func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||||
|
||||
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
|
||||
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
|
||||
if len(ids) > MaxPostAttachments {
|
||||
maxC := s.maxCount()
|
||||
if len(ids) > maxC {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -164,7 +217,6 @@ func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint
|
||||
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 {
|
||||
@@ -220,7 +272,7 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
|
||||
// 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 {
|
||||
@@ -268,7 +320,6 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user