feat(attach): 附件可配置限额、流式落盘与分阶段上传

管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 07:33:16 +08:00
parent 6cc434f103
commit 61bf9fb48c
24 changed files with 2513 additions and 307 deletions

View File

@@ -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