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

@@ -8,9 +8,11 @@ import (
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/freefire/jiang13-bbs/model"
@@ -31,14 +33,32 @@ const (
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
type UploadService struct {
db *gorm.DB
dir string // 上传根目录(如 data/uploads)
db *gorm.DB
dir string // 上传根目录(如 data/uploads)
setting *SettingService
}
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
return &UploadService{db: db, dir: uploadDir}
}
// WithSetting 注入站点设置(插图体积上限)
func (s *UploadService) WithSetting(setting *SettingService) *UploadService {
s.setting = setting
return s
}
func (s *UploadService) imageMaxBytes() int64 {
if s.setting == nil {
return ImageMaxBytes
}
n, err := s.setting.ImageMaxBytes()
if err != nil || n < 1 {
return ImageMaxBytes
}
return n
}
// EnsureDir 启动时确保上传目录存在
func (s *UploadService) EnsureDir() error {
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
@@ -125,8 +145,7 @@ func detectImageFormat(data []byte) (imageFormat, error) {
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
}
func decodeImageSize(data []byte, mime string) (w, h int, err error) {
r := bytes.NewReader(data)
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
var cfg image.Config
switch mime {
case "image/webp":
@@ -140,28 +159,33 @@ func decodeImageSize(data []byte, mime string) (w, h int, err error) {
return cfg.Width, cfg.Height, nil
}
// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
if len(data) == 0 {
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
}
if len(data) > ImageMaxBytes {
return nil, errors.New("图片不能超过 5MB")
maxB := s.imageMaxBytes()
tooLarge := func() error {
mb := int(maxB >> 20)
if mb < 1 {
mb = 1
}
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
}
format, err := detectImageFormat(data)
// 先读魔数头,再与剩余流合并落盘,整图不进内存
head := make([]byte, 12)
n, err := io.ReadFull(src, head)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return nil, errors.New("读取图片失败")
}
if n == 0 {
return nil, errors.New("文件为空")
}
format, err := detectImageFormat(head[:n])
if err != nil {
return nil, err
}
w, h, err := decodeImageSize(data, format.mime)
if err != nil {
return nil, err
}
if w < 1 || h < 1 {
return nil, errors.New("无效的图片尺寸")
}
if w > ImageMaxDim || h > ImageMaxDim {
return nil, errors.New("图片边长不能超过 4096px")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
@@ -169,7 +193,61 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
}
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "images", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
tmp := fullPath + ".partial"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, err
}
remain := maxB + 1 - int64(n)
if remain < 0 {
_ = f.Close()
_ = os.Remove(tmp)
return nil, tooLarge()
}
writtenHead, err := f.Write(head[:n])
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return nil, err
}
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return nil, copyErr
}
total := int64(writtenHead) + writtenRest
if total == 0 {
_ = os.Remove(tmp)
return nil, errors.New("文件为空")
}
if total > maxB {
_ = os.Remove(tmp)
return nil, tooLarge()
}
rf, err := os.Open(tmp)
if err != nil {
_ = os.Remove(tmp)
return nil, err
}
w, h, err := decodeImageSizeReader(rf, format.mime)
_ = rf.Close()
if err != nil {
_ = os.Remove(tmp)
return nil, err
}
if w < 1 || h < 1 {
_ = os.Remove(tmp)
return nil, errors.New("无效的图片尺寸")
}
if w > ImageMaxDim || h > ImageMaxDim {
_ = os.Remove(tmp)
return nil, errors.New("图片边长不能超过 4096px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
}
@@ -178,7 +256,7 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: len(data),
Size: int(total),
Width: w,
Height: h,
}